Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: passlink authentication #1467

Draft
wants to merge 19 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions backend/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ package config

import (
"fmt"
"log"

"github.com/kelseyhightower/envconfig"
"github.com/knadh/koanf"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/file"
"github.com/teamhanko/hanko/backend/ee/saml/config"
"log"
)

// Config is the central configuration type
Expand All @@ -34,8 +35,10 @@ type Config struct {
Log LoggerConfig `yaml:"log" json:"log,omitempty" koanf:"log" jsonschema:"title=log"`
// Deprecated. See child properties for suggested replacements.
Passcode Passcode `yaml:"passcode" json:"passcode,omitempty" koanf:"passcode" jsonschema:"title=passcode"`
// `passkey` configures how passkeys are acquired and used.
// `passkey` configures how passkeys are acquired and used.
Passkey Passkey `yaml:"passkey" json:"passkey,omitempty" koanf:"passkey" jsonschema:"title=passkey"`
// `passlink` congigures how passlinks are acquired and used.
Passlink Passlink `yaml:"passlink" json:"passlink,omitempty" koanf:"passlink"`
// `password` configures how passwords are acquired and used.
Password Password `yaml:"password" json:"password,omitempty" koanf:"password" jsonschema:"title=password"`
// `rate_limiter` configures rate limits for rate limited API operations and storage modalities for rate limit data.
Expand Down Expand Up @@ -167,6 +170,10 @@ func (c *Config) Validate() error {
if err != nil {
return fmt.Errorf("failed to validate webhook settings: %w", err)
}
err = c.Passlink.Validate()
if err != nil {
return fmt.Errorf("failed to validate passlink settings: %w", err)
}
return nil
}

Expand Down
3 changes: 3 additions & 0 deletions backend/config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ password:
acquire_on_login: never
recovery: true
min_length: 8
passlink:
enabled: true
url: http://localhost:3000
rate_limiter:
enabled: true
store: in_memory
Expand Down
8 changes: 8 additions & 0 deletions backend/config/config_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ func DefaultConfig() *Config {
Recovery: true,
MinLength: 8,
},
Passlink: Passlink{
Enabled: false,
URL: "http://localhost:8888",
},
Database: Database{
Database: "hanko",
User: "hanko",
Expand Down Expand Up @@ -98,6 +102,10 @@ func DefaultConfig() *Config {
Tokens: 3,
Interval: 1 * time.Minute,
},
PasslinkLimits: RateLimits{
Tokens: 3,
Interval: 1 * time.Minute,
},
TokenLimits: RateLimits{
Tokens: 3,
Interval: 1 * time.Minute,
Expand Down
2 changes: 2 additions & 0 deletions backend/config/config_email.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ type Email struct {
Optional bool `yaml:"optional" json:"optional,omitempty" koanf:"optional" jsonschema:"default=false"`
// `passcode_ttl` specifies, in seconds, how long a passcode is valid for.
PasscodeTtl int `yaml:"passcode_ttl" json:"passcode_ttl,omitempty" koanf:"passcode_ttl" jsonschema:"default=300"`
// `passlink_ttl` specifies, in seconds, how long a passlink is valid for.
PasslinkTtl int `yaml:"passlink_ttl" json:"passlink_ttl,omitempty" koanf:"passlink_ttl" jsonschema:"default=300"`
// `require_verification` determines whether newly created emails must be verified by providing a passcode sent
// to respective address.
RequireVerification bool `yaml:"require_verification" json:"require_verification,omitempty" koanf:"require_verification" split_words:"true" jsonschema:"default=true"`
Expand Down
29 changes: 29 additions & 0 deletions backend/config/config_passlink.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package config

import (
"errors"
"fmt"
"net/url"
"strings"
)

type Passlink struct {
// `enabled` determines whether users can authenticate via a link containing a short-living token send by mail.
Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=false"`
// `url` is the redirect target URL for passlinks to your frontend.
// Frontend must be able to handle the passlink token and call the passlink finalize endpoint to complete the authentication.
// The passlink id (plid) and the token (pltk) are added as query parameters to that URL.
URL string `yaml:"url" json:"url,omitempty" koanf:"url"`
}

func (p *Passlink) Validate() error {
if len(strings.TrimSpace(p.URL)) == 0 {
return errors.New("url must not be empty")
}
if url, err := url.Parse(p.URL); err != nil {
return fmt.Errorf("failed to parse url: %w", err)
} else if url.Scheme == "" || url.Host == "" {
return errors.New("url must be a valid URL")
}
return nil
}
2 changes: 2 additions & 0 deletions backend/config/config_rate_limiter.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ type RateLimiter struct {
Redis *RedisConfig `yaml:"redis_config" json:"redis_config,omitempty" koanf:"redis_config"`
// `passcode_limits` controls rate limits for passcode operations.
PasscodeLimits RateLimits `yaml:"passcode_limits" json:"passcode_limits,omitempty" koanf:"passcode_limits" split_words:"true"`
// `passlink_limits` controls rate limits for passlink operations.
PasslinkLimits RateLimits `yaml:"passlink_limits" json:"passlink_limits,omitempty" koanf:"passlink_limits" split_words:"true"`
// `password_limits` controls rate limits for password login operations.
PasswordLimits RateLimits `yaml:"password_limits" json:"password_limits,omitempty" koanf:"password_limits" split_words:"true"`
// `token_limits` controls rate limits for token exchange operations.
Expand Down
28 changes: 28 additions & 0 deletions backend/crypto/passlink.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package crypto

import (
"crypto/rand"
"encoding/hex"
"log"
)

// PasslinkGenerator will generate a random passlink token
type PasslinkGenerator interface {
Generate() (string, error)
}

type passlinkGenerator struct {
}

func NewPasslinkGenerator() PasslinkGenerator {
return &passlinkGenerator{}
}

func (g *passlinkGenerator) Generate() (string, error) {
bytes := make([]byte, 32)
_, err := rand.Read(bytes)
if err != nil {
log.Fatal(err)
}
return hex.EncodeToString(bytes), nil
}
43 changes: 43 additions & 0 deletions backend/dto/admin/passlink.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package admin

import (
"time"

"github.com/gofrs/uuid"
"github.com/teamhanko/hanko/backend/persistence/models"
)

type Passlink struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
EmailID uuid.UUID `json:"email_id"`
Email *Email `json:"email,omitempty"`
TTL int `json:"ttl"` // in seconds
LoginCount int `json:"login_count"`
Reusable bool `json:"reusable"` // by default a passlink can only used once, if reusable is set true, it can be used to authenticate the user multiple times by clicking the same link (e.g. in a newsletter)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

// FromPasslinkModel Converts the DB model to a DTO object
func FromPasslinkModel(model models.Passlink) Passlink {
return Passlink{
ID: model.ID,
UserID: model.UserID,
EmailID: model.EmailID,
Email: FromEmailModel(&model.Email),
TTL: model.TTL,
LoginCount: model.LoginCount,
Reusable: model.Reusable,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}
}

type CreatePasslink struct {
ID *uuid.UUID `json:"id,omitempty"`
UserID uuid.UUID `json:"user_id"`
EmailID uuid.UUID `json:"email_id"`
TTL int `json:"ttl"` // in seconds
Reusable bool `json:"reusable"` // by default a passlink can only used once, if reusable is set true, it can be used to authenticate the user multiple times by clicking the same link (e.g. in a newsletter)
}
2 changes: 2 additions & 0 deletions backend/dto/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
// PublicConfig is the part of the configuration that will be shared with the frontend
type PublicConfig struct {
Password Password `json:"password"`
Passlink bool `json:"passlink"`
Emails Emails `json:"emails"`
Providers []string `json:"providers"`
Account Account `json:"account"`
Expand Down Expand Up @@ -37,6 +38,7 @@ func FromConfig(cfg config.Config) PublicConfig {
Enabled: cfg.Password.Enabled,
MinLength: cfg.Password.MinLength,
},
Passlink: cfg.Passlink.Enabled,
Emails: Emails{
RequireVerification: cfg.Email.RequireVerification,
MaxNumOfAddresses: cfg.Email.Limit,
Expand Down
22 changes: 22 additions & 0 deletions backend/dto/passlink.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package dto

import (
"time"
)

type PasslinkFinishRequest struct {
ID string `json:"id" validate:"required,uuid4"`
Token string `json:"token" validate:"required"`
}

type PasslinkInitRequest struct {
UserID string `json:"user_id" validate:"required,uuid4"`
EmailID *string `json:"email_id"`
RedirectPath string `json:"redirect_path" validate:"required"`
}

type PasslinkReturn struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
UserID string `json:"user_id"`
}
19 changes: 15 additions & 4 deletions backend/dto/webhook/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,25 @@ type EmailSend struct {
}

type PasscodeData struct {
ServiceName string `json:"service_name"`
OtpCode string `json:"otp_code"`
TTL int `json:"ttl"`
ValidUntil int64 `json:"valid_until"` // UnixTimestamp
ServiceName string `json:"service_name" mapstructure:"service_name"`
OtpCode string `json:"otp_code" mapstructure:"otp_code"`
TTL int `json:"ttl" mapstructure:"ttl"`
ValidUntil int64 `json:"valid_until" mapstructure:"valid_until"` // UnixTimestamp
}

type PasslinkData struct {
ServiceName string `json:"service_name" mapstructure:"service_name"`
Token string `json:"token" mapstructure:"token"`
URL string `json:"url" mapstructure:"url"`
TTL int `json:"ttl" mapstructure:"ttl"`
ValidUntil int64 `json:"valid_until" mapstructure:"valid_until"` // UnixTimestamp
RedirectPath string `json:"redirect_path" mapstructure:"redirect_path"`
RetryLimit int `json:"retry_limit" mapstructure:"retry_limit"`
}

type EmailType string

var (
EmailTypePasscode EmailType = "passcode"
EmailTypePasslink EmailType = "passlink"
)
6 changes: 4 additions & 2 deletions backend/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
module github.com/teamhanko/hanko/backend

go 1.20
go 1.21

toolchain go1.22.3

require (
github.com/brianvoe/gofakeit/v6 v6.28.0
Expand Down Expand Up @@ -41,6 +43,7 @@ require (
github.com/stretchr/testify v1.9.0
github.com/tidwall/gjson v1.16.0
github.com/tidwall/sjson v1.2.5
github.com/wk8/go-ordered-map/v2 v2.1.8
golang.org/x/crypto v0.24.0
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1
golang.org/x/oauth2 v0.21.0
Expand Down Expand Up @@ -150,7 +153,6 @@ require (
github.com/tidwall/pretty v1.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
Expand Down
9 changes: 9 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/ClickHouse/ch-go v0.55.0 h1:jw4Tpx887YXrkyL5DfgUome/po8MLz92nz2heOQ6RjQ=
github.com/ClickHouse/ch-go v0.55.0/go.mod h1:kQT2f+yp2p+sagQA/7kS6G3ukym+GQ5KAu1kuFAFDiU=
github.com/ClickHouse/clickhouse-go/v2 v2.9.1 h1:IeE2bwVvAba7Yw5ZKu98bKI4NpDmykEy6jUaQdJJCk8=
Expand Down Expand Up @@ -78,12 +79,14 @@ github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw=
github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo=
github.com/docker/cli v23.0.1+incompatible h1:LRyWITpGzl2C9e9uGxzisptnxAn1zfZKXy13Ul2Q5oM=
github.com/docker/cli v23.0.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0=
Expand Down Expand Up @@ -126,6 +129,7 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
Expand Down Expand Up @@ -188,7 +192,9 @@ github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzq
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
Expand Down Expand Up @@ -222,6 +228,7 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
Expand Down Expand Up @@ -372,6 +379,7 @@ github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
Expand Down Expand Up @@ -883,6 +891,7 @@ gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.3.0 h1:MfDY1b1/0xN1CyMlQDac0ziEy9zJQd9CXBRRDHw2jJo=
gotest.tools/v3 v3.3.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
Expand Down
Loading