Skip to content

Commit

Permalink
feat: support for sending link for authenticating users via email (#286)
Browse files Browse the repository at this point in the history
Signed-off-by: Kush Sharma <[email protected]>
  • Loading branch information
kushsharma authored Aug 1, 2023
1 parent 2738405 commit a591ae3
Show file tree
Hide file tree
Showing 22 changed files with 206 additions and 89 deletions.
1 change: 1 addition & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ func buildAPIDependencies(
cfg.App.Mailer.SMTPInsecure,
cfg.App.Mailer.Headers,
)
logger.Info("mailer enabled", "host", cfg.App.Mailer.SMTPHost, "port", cfg.App.Mailer.SMTPPort)
}
authnService := authenticate.NewService(logger, cfg.App.Authentication,
postgres.NewFlowRepository(logger, dbc), mailDialer, tokenService, sessionService, userService, serviceUserService)
Expand Down
11 changes: 11 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,16 @@ func Load(serverConfigFileFromFlag string) (*Frontier, error) {
return nil, err
}
}

// post config load hooks for backward compatibility
conf = postLoad(conf)

return conf, nil
}

func postLoad(conf *Frontier) *Frontier {
if conf.App.Authentication.OIDCCallbackHost != "" && conf.App.Authentication.CallbackHost == "" {
conf.App.Authentication.CallbackHost = conf.App.Authentication.OIDCCallbackHost
}
return conf
}
14 changes: 10 additions & 4 deletions config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ app:
iss: "http://localhost.shield"
# validity of the token
validity: "1h"
# external host used for oidc redirect uri, e.g. http://localhost:8000/v1beta1/auth/callback
oidc_callback_host: http://localhost:8000/v1beta1/auth/callback
# public facing host used for oidc redirect uri and mail link redirection
# e.g. http://localhost:7400/v1beta1/auth/callback
callback_host: http://localhost:8000/v1beta1/auth/callback
# oidc auth server configs
oidc_config:
google:
Expand All @@ -70,8 +71,13 @@ app:
mail_otp:
subject: "Shield - Login Link"
# body is a go template with `Otp` as a variable
body: "Please copy/paste the OneTimePassword in login form.<h2>{{.Otp}}</h2>This code will expire in 10 minutes."
validity: "1h"
body: "Please copy/paste the OneTimePassword in login form.<h2>{{.Otp}}</h2>This code will expire in 15 minutes."
validity: 15m
mail_link:
subject: "Frontier Login - One time link"
# body is a go template with `Otp` as a variable
body: "Click on the following link or copy/paste the url in browser to login.<br><h2><a href='{{.Link}}' target='_blank'>Login</a></h2><br>Address: {{.Link}} <br>This link will expire in 15 minutes."
validity: 15m
# platform level administration
admin:
# Email list of users which needs to be converted as superusers
Expand Down
11 changes: 6 additions & 5 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ func TestLoad(t *testing.T) {
want: &Frontier{
Version: 1,
DB: db.Config{
URL: "postgres://frontier:@localhost:5432/frontier?sslmode=disable",
MaxIdleConns: 10,
MaxOpenConns: 10,
ConnMaxLifeTime: time.Duration(10) * time.Millisecond,
MaxQueryTimeoutInMS: time.Duration(500) * time.Millisecond,
Driver: "postgres",
URL: "postgres://frontier:@localhost:5432/frontier?sslmode=disable",
MaxIdleConns: 10,
MaxOpenConns: 10,
ConnMaxLifeTime: time.Duration(10) * time.Millisecond,
MaxQueryTimeout: time.Duration(500) * time.Millisecond,
},
SpiceDB: spicedb.Config{
Host: "spicedb.localhost",
Expand Down
5 changes: 4 additions & 1 deletion core/authenticate/authenticate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package authenticate
import (
"time"

"github.com/raystack/frontier/core/authenticate/strategy"

"github.com/raystack/frontier/core/serviceuser"
"github.com/raystack/frontier/core/user"

Expand All @@ -14,7 +16,8 @@ import (
type AuthMethod string

const (
MailOTPAuthMethod AuthMethod = "mailotp"
MailOTPAuthMethod = AuthMethod(strategy.MailOTPAuthMethod)
MailLinkAuthMethod = AuthMethod(strategy.MailLinkAuthMethod)
)

func (m AuthMethod) String() string {
Expand Down
19 changes: 15 additions & 4 deletions core/authenticate/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ package authenticate
import "time"

type Config struct {
// OIDCCallbackHost is external host used for oidc redirect uri
OIDCCallbackHost string `yaml:"oidc_callback_host" mapstructure:"oidc_callback_host"`
// CallbackHost is external host used for redirect uri
CallbackHost string `yaml:"callback_host" mapstructure:"callback_host" default:"http://localhost:7400/v1beta1/auth/callback"`

// OIDCCallbackHost is external host used for redirect uri
// Deprecated: use CallbackHost instead
OIDCCallbackHost string `yaml:"oidc_callback_host" mapstructure:"oidc_callback_host" default:"http://localhost:7400/v1beta1/auth/oidc/callback"`

OIDCConfig map[string]OIDCConfig `yaml:"oidc_config" mapstructure:"oidc_config"`
Session SessionConfig `yaml:"session" mapstructure:"session"`
Token TokenConfig `yaml:"token" mapstructure:"token"`
MailOTP MailOTPConfig `yaml:"mail_otp" mapstructure:"mail_otp"`
MailLink MailLinkConfig `yaml:"mail_link" mapstructure:"mail_link"`
}

type TokenConfig struct {
Expand Down Expand Up @@ -40,7 +45,13 @@ type OIDCConfig struct {
}

type MailOTPConfig struct {
Subject string `yaml:"subject" mapstructure:"subject" default:"Frontier Login OTP"`
Body string `yaml:"body" mapstructure:"body" default:"Please copy/paste the OneTimePassword in login form.<h2>{{.Otp}}</h2>This code will expire in 10 minutes."`
Subject string `yaml:"subject" mapstructure:"subject" default:"Frontier Login - OTP"`
Body string `yaml:"body" mapstructure:"body" default:"Please copy/paste the One Time Password in login form.<h2>{{.Otp}}</h2>This code will expire in 10 minutes."`
Validity time.Duration `yaml:"validity" mapstructure:"validity" default:"10m"`
}

type MailLinkConfig struct {
Subject string `yaml:"subject" mapstructure:"subject" default:"Frontier Login - One time link"`
Body string `yaml:"body" mapstructure:"body" default:"Click on the following link or copy/paste the url in browser to login.<h3><a href='{{.Link}}' target='_blank'>Login</a></h3>Address: {{.Link}} <br>This link will expire in 10 minutes."`
Validity time.Duration `yaml:"validity" mapstructure:"validity" default:"10m"`
}
40 changes: 29 additions & 11 deletions core/authenticate/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,8 @@ import (
)

const (
// TODO(kushsharma): should we expose this in config?
defaultFlowExp = time.Minute * 10
maxOTPAttempt = 5
maxOTPAttempt = 3
otpAttemptKey = "attempt"
)

Expand Down Expand Up @@ -111,7 +110,7 @@ func (s Service) SupportedStrategies() []string {
strategies = append(strategies, name)
}
if s.mailDialer != nil {
strategies = append(strategies, MailOTPAuthMethod.String())
strategies = append(strategies, MailOTPAuthMethod.String(), MailLinkAuthMethod.String())
}
return strategies
}
Expand All @@ -129,7 +128,7 @@ func (s Service) StartFlow(ctx context.Context, request RegistrationStartRequest
}

if request.Method == MailOTPAuthMethod.String() {
mailLinkStrat := strategy.NewMailLink(s.mailDialer, s.config.MailOTP.Subject, s.config.MailOTP.Body)
mailLinkStrat := strategy.NewMailOTP(s.mailDialer, s.config.MailOTP.Subject, s.config.MailOTP.Body)
nonce, err := mailLinkStrat.SendMail(request.Email)
if err != nil {
return nil, err
Expand All @@ -148,13 +147,32 @@ func (s Service) StartFlow(ctx context.Context, request RegistrationStartRequest
State: flow.ID.String(),
}, nil
}
if request.Method == MailLinkAuthMethod.String() {
mailLinkStrat := strategy.NewMailLink(s.mailDialer, s.config.CallbackHost, s.config.MailLink.Subject, s.config.MailLink.Body)
nonce, err := mailLinkStrat.SendMail(flow.ID.String(), request.Email)
if err != nil {
return nil, err
}

flow.Nonce = nonce
if s.config.MailLink.Validity != 0 {
flow.ExpiresAt = flow.CreatedAt.Add(s.config.MailLink.Validity)
}
flow.Email = strings.ToLower(request.Email)
if err = s.flowRepo.Set(ctx, flow); err != nil {
return nil, err
}
return &RegistrationStartResponse{
Flow: flow,
}, nil
}

// check for oidc flow
if oidcConfig, ok := s.config.OIDCConfig[request.Method]; ok {
idp, err := strategy.NewRelyingPartyOIDC(
oidcConfig.ClientID,
oidcConfig.ClientSecret,
s.config.OIDCCallbackHost).
s.config.CallbackHost).
Init(ctx, oidcConfig.IssuerUrl)
if err != nil {
return nil, err
Expand Down Expand Up @@ -186,8 +204,8 @@ func (s Service) StartFlow(ctx context.Context, request RegistrationStartRequest
}

func (s Service) FinishFlow(ctx context.Context, request RegistrationFinishRequest) (*RegistrationFinishResponse, error) {
if request.Method == MailOTPAuthMethod.String() {
response, err := s.applyMail(ctx, request)
if request.Method == MailOTPAuthMethod.String() || request.Method == MailLinkAuthMethod.String() {
response, err := s.applyMailOTP(ctx, request)
if err != nil && !errors.Is(err, ErrStrategyNotApplicable) {
return nil, err
}
Expand All @@ -207,10 +225,10 @@ func (s Service) FinishFlow(ctx context.Context, request RegistrationFinishReque
return nil, ErrUnsupportedMethod
}

// applyMail actions when user submitted otp from the email
// user can be considered as verified if correct
// applyMailOTP actions when user submitted otp from the email
// user can be considered as verified if code is valid
// create a new user if required
func (s Service) applyMail(ctx context.Context, request RegistrationFinishRequest) (*RegistrationFinishResponse, error) {
func (s Service) applyMailOTP(ctx context.Context, request RegistrationFinishRequest) (*RegistrationFinishResponse, error) {
if len(request.Code) == 0 {
return nil, ErrStrategyNotApplicable
}
Expand Down Expand Up @@ -289,7 +307,7 @@ func (s Service) applyOIDC(ctx context.Context, request RegistrationFinishReques
idp, err := strategy.NewRelyingPartyOIDC(
oidcConfig.ClientID,
oidcConfig.ClientSecret,
s.config.OIDCCallbackHost).
s.config.CallbackHost).
Init(ctx, oidcConfig.IssuerUrl)
if err != nil {
return nil, err
Expand Down
65 changes: 65 additions & 0 deletions core/authenticate/strategy/mail_link.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package strategy

import (
"bytes"
"fmt"
"html/template"
"strings"
"time"

"github.com/raystack/frontier/pkg/mailer"
"gopkg.in/mail.v2"
)

const (
MailLinkAuthMethod string = "maillink"
)

// MailLink sends a mail with a one time password link to user's email id.
// On successful verification, it creates a session
type MailLink struct {
dialer mailer.Dialer
subject string
body string
Now func() time.Time
host string
}

func NewMailLink(d mailer.Dialer, host, subject, body string) *MailLink {
return &MailLink{
host: host,
dialer: d,
subject: subject,
body: body,
Now: func() time.Time {
return time.Now().UTC()
},
}
}

// SendMail sends a mail with a one time password embedded link to user's email id
func (m MailLink) SendMail(id, to string) (string, error) {
otp := GenerateNonceFromLetters(otpLen, otpLetterRunes)
t, err := template.New("body").Parse(m.body)
if err != nil {
return "", fmt.Errorf("failed to parse email template: %w", err)
}
var tpl bytes.Buffer

link := fmt.Sprintf("%s?strategy_name=%s&code=%s&state=%s", strings.TrimRight(m.host, "/"), MailLinkAuthMethod, otp, id)
err = t.Execute(&tpl, map[string]string{
"Link": link,
})
if err != nil {
return "", fmt.Errorf("failed to parse email template: %w", err)
}

//TODO(kushsharma): apply rest of the headers
msg := mail.NewMessage()
msg.SetHeader("From", m.dialer.FromHeader())
msg.SetHeader("To", to)
msg.SetHeader("Subject", m.subject)
msg.SetBody("text/html", tpl.String())
msg.SetDateHeader("Date", m.Now())
return otp, m.dialer.DialAndSend(msg)
}
6 changes: 5 additions & 1 deletion core/authenticate/strategy/mail_otp.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import (
"gopkg.in/mail.v2"
)

const (
MailOTPAuthMethod string = "mailotp"
)

var (
otpLetterRunes = []rune("ABCDEFGHJKMNPQRSTWXYZ23456789")
otpLen = 6
Expand All @@ -25,7 +29,7 @@ type MailOTP struct {
Now func() time.Time
}

func NewMailLink(d mailer.Dialer, subject, body string) *MailOTP {
func NewMailOTP(d mailer.Dialer, subject, body string) *MailOTP {
return &MailOTP{
dialer: d,
subject: subject,
Expand Down
9 changes: 5 additions & 4 deletions docs/docs/authn/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ Or if frontier server is running behind a proxy server, you need to specify the
then forward the request to Frontier server at `/v1beta1/auth/callback`.

This callback url is used by the third party provider to redirect the user back to the Frontier server after successful
authentication. Same config needs to be specified in the `oidc_callback_host` field of the `config.yaml` file.
`oidc_callback_host` is required to generate the login URL for the third party provider that initiates the authentication
authentication. Same config needs to be specified in the `callback_host` field of the `config.yaml` file.
`callback_host` is required to generate the login URL for the third party provider that initiates the authentication
flow.

```yaml
Expand Down Expand Up @@ -83,8 +83,9 @@ app:
iss: "http://localhost.frontier"
# validity of the token
validity: "1h"
# external host used for oidc redirect uri, e.g. http://localhost:8000/v1beta1/auth/callback
oidc_callback_host: http://localhost:8000/v1beta1/auth/callback
# public facing host used for oidc redirect uri and mail link redirection
# e.g. http://localhost:7400/v1beta1/auth/callback
callback_host: http://localhost:8000/v1beta1/auth/callback
# oidc auth server configs
oidc_config:
google:
Expand Down
25 changes: 13 additions & 12 deletions docs/docs/reference/configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ app:
iss: "http://localhost.frontier"
# validity of the token
validity: "1h"
# external host used for oidc redirect uri, e.g. http://localhost:8000/v1beta1/auth/callback
oidc_callback_host: http://localhost:8000/v1beta1/auth/callback
# public facing host used for oidc redirect uri and mail link redirection
# e.g. http://localhost:7400/v1beta1/auth/callback
callback_host: http://localhost:8000/v1beta1/auth/callback
# oidc auth server configs
oidc_config:
google:
Expand Down Expand Up @@ -145,16 +146,16 @@ This page contains reference for all the application configurations for Frontier

Configuration to allow authentication in Frontier.

| **Field** | **Description** | **Required** | **Example** |
| ------------------------------------------------------- | -------------------------------------------------- | ------------ | --------------------------------------------- |
| **app.authentication.session.hash_secret_key** | Secret key for session hashing. | Yes | "hash-secret-should-be-32-chars--" |
| **app.authentication.session.block_secret_key** | Secret key for session encryption. | Yes | "block-secret-should-be-32-chars-" |
| **app.authentication.token.rsa_path** | Path to the RSA key file for token authentication. | Yes | "./temp/rsa" |
| **app.authentication.token.iss** | Issuer URL for token authentication. | Yes | "http://localhost.frontier" |
| **app.authentication.oidc_callback_host** | External host used for OIDC redirect URI. | Yes | "http://localhost:8000/v1beta1/auth/callback" |
| **app.authentication.oidc_config.google.client_id** | Google client ID for OIDC authentication. | No | "xxxxx.apps.googleusercontent.com" |
| **app.authentication.oidc_config.google.client_secret** | Google client secret for OIDC authentication. | No | "xxxxx" |
| **app.authentication.oidc_config.google.issuer_url** | Google issuer URL for OIDC authentication. | No | "https://accounts.google.com" |
| **Field** | **Description** | **Required** | **Example** |
| -------------------------------------------------- |-----------------------------------------------------| ------------ | --------------------------------------------- |
| **app.authentication.session.hash_secret_key** | Secret key for session hashing. | Yes | "hash-secret-should-be-32-chars--" |
| **app.authentication.session.block_secret_key** | Secret key for session encryption. | Yes | "block-secret-should-be-32-chars-" |
| **app.authentication.token.rsa_path** | Path to the RSA key file for token authentication. | Yes | "./temp/rsa" |
| **app.authentication.token.iss** | Issuer URL for token authentication. | Yes | "http://localhost.frontier" |
| **app.authentication.callback_host** | External host used for OIDC/Mail link redirect URI. | Yes | "http://localhost:8000/v1beta1/auth/callback" |
| **app.authentication.oidc_config.google.client_id** | Google client ID for OIDC authentication. | No | "xxxxx.apps.googleusercontent.com" |
| **app.authentication.oidc_config.google.client_secret** | Google client secret for OIDC authentication. | No | "xxxxx" |
| **app.authentication.oidc_config.google.issuer_url** | Google issuer URL for OIDC authentication. | No | "https://accounts.google.com" |

### Admin Configurations

Expand Down
4 changes: 2 additions & 2 deletions docs/docs/tour/setup-idp-oidc.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Follow the steps below to configure OIDC authentication via an external IDP:
- Add the following OIDC-related configurations under **`app.authentication`** section:

```yaml
oidc_callback_host: http://localhost:8000/v1beta1/auth/callback
callback_host: http://localhost:8000/v1beta1/auth/callback
oidc_config:
google:
client_id: "xxxxx.apps.googleusercontent.com"
Expand All @@ -39,7 +39,7 @@ Follow the steps below to configure OIDC authentication via an external IDP:
- Replace **xxxxx.apps.googleusercontent.com** with your Google Client ID.
- Replace **xxxxx** with your Google Client Secret.
- Ensure that **oidc_callback_host** matches the **callback URL** you determined in step 1.
- Ensure that **callback_host** matches the **callback URL** you determined in step 1.
- Update **issuer_url** if you're using a different IDP.
:::tip Tip
Expand Down
Loading

0 comments on commit a591ae3

Please sign in to comment.