Skip to content

Commit f40f579

Browse files
committed
feat: add Modica Group SMS provider support
- Add ModicaSMS provider implementing controller.SMSer interface - Support provider selection via AUTH_SMS_PROVIDER environment variable - Maintain backward compatibility with existing Twilio configuration - Add extensive test coverage including mock server and validation tests
1 parent 183bd83 commit f40f579

File tree

9 files changed

+427
-2
lines changed

9 files changed

+427
-2
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

go/cmd/config.go

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

go/cmd/email.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ func getSMS( //nolint:ireturn
116116
provider = "twilio" // Default to Twilio for backward compatibility
117117
}
118118

119-
switch strings.ToLower(cmd.String(flagSMSProvider)) {
119+
switch provider {
120+
case "modica":
121+
return getModicaSMS(cmd, templates, db, logger)
120122
case "twilio":
121123
return getTwilioSMS(cmd, templates, db)
122124
case "dev":
@@ -154,3 +156,33 @@ func getTwilioSMS( //nolint:ireturn
154156
db,
155157
), nil
156158
}
159+
160+
func getModicaSMS( //nolint:ireturn
161+
cmd *cli.Command,
162+
templates *notifications.Templates,
163+
db *sql.Queries,
164+
logger *slog.Logger,
165+
) (controller.SMSer, error) {
166+
username := cmd.String(flagSMSModicaUsername)
167+
password := cmd.String(flagSMSModicaPassword)
168+
169+
if username == "" || password == "" {
170+
return nil, errors.New("SMS is enabled but Modica credentials are missing") //nolint:err113
171+
}
172+
173+
if templates == nil {
174+
var err error
175+
176+
templates, err = getTemplates(cmd, logger)
177+
if err != nil {
178+
return nil, fmt.Errorf("problem creating templates: %w", err)
179+
}
180+
}
181+
182+
return sms.NewModicaSMS(
183+
templates,
184+
username,
185+
password,
186+
db,
187+
), nil
188+
}

go/cmd/serve.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ const (
9999
flagSMSTwilioAccountSid = "sms-twilio-account-sid"
100100
flagSMSTwilioAuthToken = "sms-twilio-auth-token" //nolint:gosec
101101
flagSMSTwilioMessagingServiceID = "sms-twilio-messaging-service-id"
102+
flagSMSModicaUsername = "sms-modica-username"
103+
flagSMSModicaPassword = "sms-modica-password" //nolint:gosec
102104
flagAnonymousUsersEnabled = "enable-anonymous-users"
103105
flagMfaEnabled = "mfa-enabled"
104106
flagMfaTotpIssuer = "mfa-totp-issuer"
@@ -691,6 +693,18 @@ func CommandServe() *cli.Command { //nolint:funlen,maintidx
691693
Category: "sms",
692694
Sources: cli.EnvVars("AUTH_SMS_TWILIO_MESSAGING_SERVICE_ID"),
693695
},
696+
&cli.StringFlag{ //nolint: exhaustruct
697+
Name: flagSMSModicaUsername,
698+
Usage: "Modica username for SMS",
699+
Category: "sms",
700+
Sources: cli.EnvVars("AUTH_SMS_MODICA_USERNAME"),
701+
},
702+
&cli.StringFlag{ //nolint: exhaustruct
703+
Name: flagSMSModicaPassword,
704+
Usage: "Modica password for SMS",
705+
Category: "sms",
706+
Sources: cli.EnvVars("AUTH_SMS_MODICA_PASSWORD"),
707+
},
694708
&cli.BoolFlag{ //nolint: exhaustruct
695709
Name: flagAnonymousUsersEnabled,
696710
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
@@ -55,9 +55,12 @@ func getConfig() *controller.Config {
5555
MfaEnabled: true,
5656
ServerPrefix: "",
5757
SMSPasswordlessEnabled: true,
58+
SMSProvider: "twilio",
5859
SMSTwilioAccountSid: "smsAccountSid",
5960
SMSTwilioAuthToken: "smsAuthToken",
6061
SMSTwilioMessagingServiceID: "smsMessagingServiceID",
62+
SMSModicaUsername: "modicaUsername",
63+
SMSModicaPassword: "modicaPassword",
6164
}
6265
}
6366

go/notifications/sms/modica_sms.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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+
username string, password string,
48+
db DB,
49+
) *SMS {
50+
client := &http.Client{ //nolint:exhaustruct
51+
Timeout: clientTimeoutSeconds * time.Second,
52+
Transport: &http.Transport{ //nolint:exhaustruct
53+
MaxIdleConns: maxIdleConns,
54+
IdleConnTimeout: idleTimeoutSeconds * time.Second,
55+
DisableCompression: false,
56+
TLSHandshakeTimeout: tlsTimeoutSeconds * time.Second,
57+
},
58+
}
59+
60+
return NewSMS(
61+
&ModicaSMS{
62+
client: client,
63+
username: username,
64+
password: password,
65+
},
66+
templates,
67+
db,
68+
)
69+
}
70+
71+
func (s *ModicaSMS) SendSMS(to string, body string) error {
72+
// Validate inputs according to Modica API requirements
73+
if !strings.HasPrefix(to, "+") {
74+
return ErrInvalidPhoneFormat
75+
}
76+
77+
if len(body) == 0 {
78+
return ErrEmptyMessage
79+
}
80+
81+
reqBody := modicaSMSRequest{
82+
Destination: to,
83+
Content: body,
84+
}
85+
86+
jsonData, err := json.Marshal(reqBody)
87+
if err != nil {
88+
return fmt.Errorf("failed to marshal request: %w", err)
89+
}
90+
91+
req, err := http.NewRequestWithContext(
92+
context.Background(),
93+
http.MethodPost,
94+
modicaAPIURL,
95+
bytes.NewBuffer(jsonData),
96+
)
97+
if err != nil {
98+
return fmt.Errorf("failed to create request: %w", err)
99+
}
100+
101+
// Set headers
102+
req.Header.Set("Content-Type", "application/json")
103+
req.Header.Set("Authorization", "Basic "+s.basicAuth())
104+
105+
resp, err := s.client.Do(req)
106+
if err != nil {
107+
return fmt.Errorf("failed to send request: %w", err)
108+
}
109+
defer resp.Body.Close()
110+
111+
if resp.StatusCode != http.StatusAccepted {
112+
bodyBytes, _ := io.ReadAll(resp.Body)
113+
114+
return fmt.Errorf("%w: HTTP %d: %s", ErrSMSAPIError, resp.StatusCode, string(bodyBytes))
115+
}
116+
117+
return nil
118+
}
119+
120+
func (s *ModicaSMS) basicAuth() string {
121+
auth := s.username + ":" + s.password
122+
return base64.StdEncoding.EncodeToString([]byte(auth))
123+
}

0 commit comments

Comments
 (0)