Skip to content

Commit 0f6d608

Browse files
committed
feat: add Modica Group SMS provider support
1 parent 3672af6 commit 0f6d608

File tree

10 files changed

+447
-6
lines changed

10 files changed

+447
-6
lines changed

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,13 @@ [email protected]
1717
AUTH_SMTP_USER=user
1818
AUTH_LOG_LEVEL=debug
1919
AUTH_CLIENT_URL='http://127.0.0.1:3000'
20+
# SMS configuration
21+
# AUTH_SMS_PASSWORDLESS_ENABLED=false
22+
# AUTH_SMS_PROVIDER=twilio
23+
# Twilio SMS settings
24+
# AUTH_SMS_TWILIO_ACCOUNT_SID=
25+
# AUTH_SMS_TWILIO_AUTH_TOKEN=
26+
# AUTH_SMS_TWILIO_MESSAGING_SERVICE_ID=
27+
# Modica Group SMS settings
28+
# AUTH_SMS_MODICA_USERNAME=
29+
# AUTH_SMS_MODICA_PASSWORD=

docs/configuration.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,32 @@ Hasura Auth supports email [passwordless authentication](https://en.wikipedia.or
8585

8686
Set `AUTH_EMAIL_PASSWORDLESS_ENABLED` to `true` to enable passwordless authentication.
8787

88-
<!-- TODO ## Passwordless with SMS -->
88+
### Passwordless with SMS
89+
90+
Hasura Auth supports SMS [passwordless authentication](https://en.wikipedia.org/wiki/Passwordless_authentication). It requires an SMS provider to be configured properly.
91+
92+
Set `AUTH_SMS_PASSWORDLESS_ENABLED` to `true` to enable SMS passwordless authentication.
93+
94+
#### SMS Provider Configuration
95+
96+
Configure the SMS provider using the `AUTH_SMS_PROVIDER` environment variable:
97+
98+
```bash
99+
AUTH_SMS_PROVIDER=twilio # or modica
100+
```
101+
102+
**Twilio Configuration:**
103+
```bash
104+
AUTH_SMS_TWILIO_ACCOUNT_SID=your_account_sid
105+
AUTH_SMS_TWILIO_AUTH_TOKEN=your_auth_token
106+
AUTH_SMS_TWILIO_MESSAGING_SERVICE_ID=your_messaging_service_id
107+
```
108+
109+
**Modica Group Configuration:**
110+
```bash
111+
AUTH_SMS_MODICA_USERNAME=your_username
112+
AUTH_SMS_MODICA_PASSWORD=your_password
113+
```
89114

90115
### FIDO2 Webauthn
91116

docs/environment-variables.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@
4040
| AUTH_OTP_EMAIL_ENABLED | Enables passwordless authentication by email using OTP. The SMTP server must then be configured. | `false` |
4141
| AUTH_SMS_PASSWORDLESS_ENABLED | Enables passwordless authentication by SMS. An SMS provider must then be configured. | `false` |
4242
| AUTH_SHOW_LOG_QUERY_PARAMS | Shows all query parameters in the logs. Make sure you know what you do because this setting can potentially reveal secure information. | `false` |
43-
| AUTH_SMS_PROVIDER | SMS provider name. Only `twilio` is possible as an option for now. | |
43+
| AUTH_SMS_PROVIDER | SMS provider name. Options: `twilio`, `modica` | |
4444
| AUTH_SMS_TEST_PHONE_NUMBERS | Comma separated list of test phone numbers which can be used without any provider set. **The verification code can be found in the logs upon sign in**. | |
4545
| AUTH_SMS_TWILIO_ACCOUNT_SID | | |
4646
| AUTH_SMS_TWILIO_AUTH_TOKEN | | |
4747
| AUTH_SMS_TWILIO_MESSAGING_SERVICE_ID | | |
48+
| AUTH_SMS_MODICA_USERNAME | Modica Group API username for SMS | |
49+
| AUTH_SMS_MODICA_PASSWORD | Modica Group API password for SMS | |
4850
| AUTH_EMAIL_SIGNIN_EMAIL_VERIFIED_REQUIRED | When enabled, any email-based authentication requires emails to be verified by a link sent to this email. | `true` |
4951
| AUTH_ACCESS_CONTROL_ALLOWED_REDIRECT_URLS | Comma-separated list of allowed redirect URLs that can be passed on as an option. Any sub-path will be considered valid. Supports wildcards and other [micromatch patterns](https://github.com/micromatch/micromatch#matching-features) | |
5052
| AUTH_MFA_ENABLED | Enables users to use Multi Factor Authentication. | `false` |

go/cmd/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,12 @@ func getConfig(cCtx *cli.Context) (controller.Config, error) { //nolint:funlen
112112
WebauhtnAttestationTimeout: cCtx.Duration(flagWebauthnAttestationTimeout),
113113
OTPEmailEnabled: cCtx.Bool(flagOTPEmailEnabled),
114114
SMSPasswordlessEnabled: cCtx.Bool(flagSMSPasswordlessEnabled),
115+
SMSProvider: cCtx.String(flagSMSProvider),
115116
SMSTwilioAccountSid: cCtx.String(flagSMSTwilioAccountSid),
116117
SMSTwilioAuthToken: cCtx.String(flagSMSTwilioAuthToken),
117118
SMSTwilioMessagingServiceID: cCtx.String(flagSMSTwilioMessagingServiceID),
119+
SMSModicaUsername: cCtx.String(flagSMSModicaUsername),
120+
SMSModicaPassword: cCtx.String(flagSMSModicaPassword),
118121
MfaEnabled: cCtx.Bool(flagMfaEnabled),
119122
ServerPrefix: cCtx.String(flagAPIPrefix),
120123
}, nil

go/cmd/email.go

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,26 @@ func getSMS( //nolint:ireturn
111111
return nil, nil //nolint:nilnil // SMS disabled, return nil client
112112
}
113113

114+
provider := strings.ToLower(cCtx.String(flagSMSProvider))
115+
if provider == "" {
116+
provider = "twilio" // Default to Twilio for backward compatibility
117+
}
118+
119+
switch provider {
120+
case "modica":
121+
return getModicaSMS(cCtx, templates, db, logger)
122+
case "twilio":
123+
return getTwilioSMS(cCtx, templates, db)
124+
default:
125+
return nil, fmt.Errorf("unsupported SMS provider: %s", provider) //nolint:err113
126+
}
127+
}
128+
129+
func getTwilioSMS( //nolint:ireturn
130+
cCtx *cli.Context,
131+
templates *notifications.Templates,
132+
db *sql.Queries,
133+
) (controller.SMSer, error) {
114134
accountSid := cCtx.String(flagSMSTwilioAccountSid)
115135
authToken := cCtx.String(flagSMSTwilioAuthToken)
116136
messagingServiceID := cCtx.String(flagSMSTwilioMessagingServiceID)
@@ -126,6 +146,30 @@ func getSMS( //nolint:ireturn
126146
), nil
127147
}
128148

149+
return sms.NewTwilioSMS(
150+
templates,
151+
controller.GenerateOTP,
152+
controller.HashOTP,
153+
accountSid,
154+
authToken,
155+
messagingServiceID,
156+
db,
157+
), nil
158+
}
159+
160+
func getModicaSMS( //nolint:ireturn
161+
cCtx *cli.Context,
162+
templates *notifications.Templates,
163+
db *sql.Queries,
164+
logger *slog.Logger,
165+
) (controller.SMSer, error) {
166+
username := cCtx.String(flagSMSModicaUsername)
167+
password := cCtx.String(flagSMSModicaPassword)
168+
169+
if username == "" || password == "" {
170+
return nil, errors.New("SMS is enabled but Modica credentials are missing") //nolint:err113
171+
}
172+
129173
if templates == nil {
130174
var err error
131175

@@ -135,13 +179,12 @@ func getSMS( //nolint:ireturn
135179
}
136180
}
137181

138-
return sms.NewTwilioSMS(
182+
return sms.NewModicaSMS(
139183
templates,
140184
controller.GenerateOTP,
141185
controller.HashOTP,
142-
accountSid,
143-
authToken,
144-
messagingServiceID,
186+
username,
187+
password,
145188
db,
146189
), nil
147190
}

go/cmd/serve.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,12 @@ const (
9595
flagGoogleAudience = "google-audience"
9696
flagOTPEmailEnabled = "otp-email-enabled"
9797
flagSMSPasswordlessEnabled = "sms-passwordless-enabled"
98+
flagSMSProvider = "sms-provider"
9899
flagSMSTwilioAccountSid = "sms-twilio-account-sid"
99100
flagSMSTwilioAuthToken = "sms-twilio-auth-token" //nolint:gosec
100101
flagSMSTwilioMessagingServiceID = "sms-twilio-messaging-service-id"
102+
flagSMSModicaUsername = "sms-modica-username"
103+
flagSMSModicaPassword = "sms-modica-password" //nolint:gosec
101104
flagAnonymousUsersEnabled = "enable-anonymous-users"
102105
flagMfaEnabled = "mfa-enabled"
103106
flagMfaTotpIssuer = "mfa-totp-issuer"
@@ -684,6 +687,25 @@ func CommandServe() *cli.Command { //nolint:funlen,maintidx
684687
Category: "sms",
685688
EnvVars: []string{"AUTH_SMS_TWILIO_MESSAGING_SERVICE_ID"},
686689
},
690+
&cli.StringFlag{ //nolint: exhaustruct
691+
Name: flagSMSProvider,
692+
Usage: "SMS provider (twilio or modica)",
693+
Category: "sms",
694+
EnvVars: []string{"AUTH_SMS_PROVIDER"},
695+
Value: "twilio",
696+
},
697+
&cli.StringFlag{ //nolint: exhaustruct
698+
Name: flagSMSModicaUsername,
699+
Usage: "Modica username for SMS",
700+
Category: "sms",
701+
EnvVars: []string{"AUTH_SMS_MODICA_USERNAME"},
702+
},
703+
&cli.StringFlag{ //nolint: exhaustruct
704+
Name: flagSMSModicaPassword,
705+
Usage: "Modica password for SMS",
706+
Category: "sms",
707+
EnvVars: []string{"AUTH_SMS_MODICA_PASSWORD"},
708+
},
687709
&cli.BoolFlag{ //nolint: exhaustruct
688710
Name: flagAnonymousUsersEnabled,
689711
Usage: "Enable anonymous users",

go/controller/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,12 @@ type Config struct {
6464
WebauhtnAttestationTimeout time.Duration `json:"AUTH_WEBAUTHN_ATTESTATION_TIMEOUT"`
6565
OTPEmailEnabled bool `json:"AUTH_OTP_EMAIL_ENABLED"`
6666
SMSPasswordlessEnabled bool `json:"AUTH_SMS_PASSWORDLESS_ENABLED"`
67+
SMSProvider string `json:"AUTH_SMS_PROVIDER"`
6768
SMSTwilioAccountSid string `json:"AUTH_SMS_TWILIO_ACCOUNT_SID"`
6869
SMSTwilioAuthToken string `json:"AUTH_SMS_TWILIO_AUTH_TOKEN"`
6970
SMSTwilioMessagingServiceID string `json:"AUTH_SMS_TWILIO_MESSAGING_SERVICE_ID"`
71+
SMSModicaUsername string `json:"AUTH_SMS_MODICA_USERNAME"`
72+
SMSModicaPassword string `json:"AUTH_SMS_MODICA_PASSWORD"`
7073
ServerPrefix string `json:"AUTH_SERVER_PREFIX"`
7174
}
7275

go/controller/validator_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ func getConfig() *controller.Config {
5858
SMSTwilioAccountSid: "smsAccountSid",
5959
SMSTwilioAuthToken: "smsAuthToken",
6060
SMSTwilioMessagingServiceID: "smsMessagingServiceID",
61+
SMSProvider: "twilio",
62+
SMSModicaUsername: "modicaUsername",
63+
SMSModicaPassword: "modicaPassword",
6164
}
6265
}
6366

go/notifications/sms/modica_sms.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package sms
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/base64"
7+
"encoding/json"
8+
"errors"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"strings"
13+
"time"
14+
15+
"github.com/nhost/hasura-auth/go/notifications"
16+
)
17+
18+
const (
19+
modicaAPIURL = "https://api.modicagroup.com/rest/sms/v2/messages"
20+
clientTimeoutSeconds = 30
21+
maxIdleConns = 10
22+
idleTimeoutSeconds = 30
23+
tlsTimeoutSeconds = 10
24+
)
25+
26+
var (
27+
ErrInvalidPhoneFormat = errors.New(
28+
"phone number must be in international format (e.g., +64211234567)",
29+
)
30+
ErrEmptyMessage = errors.New("message content cannot be empty")
31+
ErrSMSAPIError = errors.New("SMS API error")
32+
)
33+
34+
type ModicaSMS struct {
35+
client *http.Client
36+
username string
37+
password string
38+
}
39+
40+
type modicaSMSRequest struct {
41+
Destination string `json:"destination"`
42+
Content string `json:"content"`
43+
}
44+
45+
func NewModicaSMS(
46+
templates *notifications.Templates,
47+
otpGenerator func() (string, string, error),
48+
otpHasher func(string) (string, error),
49+
username string, password string,
50+
db DB,
51+
) *SMS {
52+
client := &http.Client{ //nolint:exhaustruct
53+
Timeout: clientTimeoutSeconds * time.Second,
54+
Transport: &http.Transport{ //nolint:exhaustruct
55+
MaxIdleConns: maxIdleConns,
56+
IdleConnTimeout: idleTimeoutSeconds * time.Second,
57+
DisableCompression: false,
58+
TLSHandshakeTimeout: tlsTimeoutSeconds * time.Second,
59+
},
60+
}
61+
62+
return NewSMS(
63+
&ModicaSMS{
64+
client: client,
65+
username: username,
66+
password: password,
67+
},
68+
otpGenerator,
69+
otpHasher,
70+
templates,
71+
db,
72+
)
73+
}
74+
75+
func (s *ModicaSMS) SendSMS(to string, body string) error {
76+
// Validate inputs according to Modica API requirements
77+
if !strings.HasPrefix(to, "+") {
78+
return ErrInvalidPhoneFormat
79+
}
80+
81+
if len(body) == 0 {
82+
return ErrEmptyMessage
83+
}
84+
85+
reqBody := modicaSMSRequest{
86+
Destination: to,
87+
Content: body,
88+
}
89+
90+
jsonData, err := json.Marshal(reqBody)
91+
if err != nil {
92+
return fmt.Errorf("failed to marshal request: %w", err)
93+
}
94+
95+
req, err := http.NewRequestWithContext(
96+
context.Background(),
97+
http.MethodPost,
98+
modicaAPIURL,
99+
bytes.NewBuffer(jsonData),
100+
)
101+
if err != nil {
102+
return fmt.Errorf("failed to create request: %w", err)
103+
}
104+
105+
// Set headers
106+
req.Header.Set("Content-Type", "application/json")
107+
req.Header.Set("Authorization", "Basic "+s.basicAuth())
108+
109+
resp, err := s.client.Do(req)
110+
if err != nil {
111+
return fmt.Errorf("failed to send request: %w", err)
112+
}
113+
defer resp.Body.Close()
114+
115+
if resp.StatusCode != http.StatusAccepted {
116+
bodyBytes, _ := io.ReadAll(resp.Body)
117+
118+
return fmt.Errorf("%w: HTTP %d: %s", ErrSMSAPIError, resp.StatusCode, string(bodyBytes))
119+
}
120+
121+
return nil
122+
}
123+
124+
func (s *ModicaSMS) basicAuth() string {
125+
auth := s.username + ":" + s.password
126+
return base64.StdEncoding.EncodeToString([]byte(auth))
127+
}

0 commit comments

Comments
 (0)