mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-17 08:29:04 +02:00
feat: restrict signup invite links to a specific email domain
This commit is contained in:
@@ -277,3 +277,13 @@ type InvalidEmailVerificationTokenError struct{}
|
||||
func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" }
|
||||
|
||||
func (e InvalidEmailVerificationTokenError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type EmailDomainNotAllowedError struct {
|
||||
Domain string
|
||||
}
|
||||
|
||||
func (e EmailDomainNotAllowedError) Error() string {
|
||||
return fmt.Sprintf("The email address must use the domain @%s", e.Domain)
|
||||
}
|
||||
|
||||
func (e EmailDomainNotAllowedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
@@ -21,6 +21,9 @@ var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-
|
||||
|
||||
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
|
||||
|
||||
// emailDomainRegex validates the domain part of an email address (e.g. "example.com" or "mail.example.co.uk")
|
||||
var emailDomainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
|
||||
|
||||
func init() {
|
||||
engine := binding.Validator.Engine().(*validator.Validate)
|
||||
|
||||
@@ -34,6 +37,9 @@ func init() {
|
||||
"client_id": func(fl validator.FieldLevel) bool {
|
||||
return ValidateClientID(fl.Field().String())
|
||||
},
|
||||
"email_domain": func(fl validator.FieldLevel) bool {
|
||||
return ValidateEmailDomain(fl.Field().String())
|
||||
},
|
||||
"ttl": func(fl validator.FieldLevel) bool {
|
||||
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
|
||||
if !ok {
|
||||
@@ -106,3 +112,22 @@ func ValidateCallbackURL(str string) bool {
|
||||
func ValidateCallbackURLPattern(raw string) bool {
|
||||
return utils.ValidateCallbackURLPattern(raw) == nil
|
||||
}
|
||||
|
||||
// ValidateEmailDomain validates an optional email domain.
|
||||
// It accepts inputs with or without a leading "@"
|
||||
func ValidateEmailDomain(domain string) bool {
|
||||
if domain == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
domain = strings.TrimPrefix(strings.ToLower(domain), "@")
|
||||
if domain == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if !emailDomainRegex.MatchString(domain) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -112,6 +112,42 @@ func TestValidateCallbackURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEmailDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"valid simple", "example.com", true},
|
||||
{"valid with leading at", "@example.com", true},
|
||||
{"valid subdomain", "mail.example.co.uk", true},
|
||||
{"valid uppercase", "EXAMPLE.COM", true},
|
||||
{"valid with at and uppercase", "@Example.Com", true},
|
||||
{"valid with hyphen", "my-domain.com", true},
|
||||
{"valid with digits", "example123.com", true},
|
||||
{"valid single char label", "a.com", true},
|
||||
{"empty", "", false},
|
||||
{"only at", "@", false},
|
||||
{"no tld", "example", false},
|
||||
{"tld too short", "example.c", false},
|
||||
{"trailing dot", "example.com.", false},
|
||||
{"leading dot", ".example.com", false},
|
||||
{"double dot", "example..com", false},
|
||||
{"label starts with hyphen", "-example.com", false},
|
||||
{"label ends with hyphen", "example-.com", false},
|
||||
{"numeric tld", "example.123", false},
|
||||
{"contains space", "exa mple.com", false},
|
||||
{"double at", "@@example.com", false},
|
||||
{"trailing space", "example.com ", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, ValidateEmailDomain(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCallbackURLPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -486,6 +486,16 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
UsageLimit: 1,
|
||||
UsageCount: 1, // Usage limit reached
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "b2c3d4e5-f6a7-4890-bcde-f01234567891",
|
||||
},
|
||||
Token: "DOMAIN1234567890",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||
UsageLimit: 5,
|
||||
UsageCount: 0,
|
||||
EmailDomain: new("example.com"),
|
||||
},
|
||||
}
|
||||
for _, token := range signupTokens {
|
||||
if err := tx.Create(&token).Error; err != nil {
|
||||
|
||||
@@ -18,14 +18,21 @@ type signupTokenCreateDto struct {
|
||||
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
|
||||
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
|
||||
UserGroupIDs []string `json:"userGroupIds"`
|
||||
EmailDomain *string `json:"emailDomain"`
|
||||
}
|
||||
|
||||
type signupTokenDto struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||
UsageLimit int `json:"usageLimit"`
|
||||
UsageCount int `json:"usageCount"`
|
||||
UserGroups []dto.UserGroupMinimalDto `json:"userGroups"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||
UsageLimit int `json:"usageLimit"`
|
||||
UsageCount int `json:"usageCount"`
|
||||
EmailDomain *string `json:"emailDomain" binding:"omitempty,email_domain"`
|
||||
UserGroups []dto.UserGroupMinimalDto `json:"userGroups"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
}
|
||||
|
||||
// signupTokenInfoDto exposes the limited, publicly readable metadata of a signup token
|
||||
type signupTokenInfoDto struct {
|
||||
EmailDomain *string `json:"emailDomain"`
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ func (h *handler) createSignupToken(c *gin.Context) {
|
||||
ttl = defaultSignupTokenDuration
|
||||
}
|
||||
|
||||
signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
|
||||
signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs, input.EmailDomain)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -159,6 +159,28 @@ func (h *handler) deleteSignupToken(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// signupTokenInfoHandler godoc
|
||||
// @Summary Get signup token info
|
||||
// @Description Get the public metadata (such as the required email domain) of a signup token
|
||||
// @Tags Users
|
||||
// @Produce json
|
||||
// @Param token path string true "Signup token"
|
||||
// @Success 200 {object} signupTokenInfoDto
|
||||
// @Router /api/signup/token/{token} [get]
|
||||
func (h *handler) signupTokenInfo(c *gin.Context) {
|
||||
token := c.Param("token")
|
||||
|
||||
signupToken, err := h.service.GetSignupTokenInfo(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, signupTokenInfoDto{
|
||||
EmailDomain: signupToken.EmailDomain,
|
||||
})
|
||||
}
|
||||
|
||||
// signupHandler godoc
|
||||
// @Summary Sign up
|
||||
// @Description Create a new user account
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package usersignup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
@@ -11,11 +12,12 @@ import (
|
||||
type SignupToken struct {
|
||||
model.Base
|
||||
|
||||
Token string `json:"token"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
|
||||
UsageLimit int `json:"usageLimit" sortable:"true"`
|
||||
UsageCount int `json:"usageCount" sortable:"true"`
|
||||
UserGroups []model.UserGroup `gorm:"many2many:signup_tokens_user_groups;"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
|
||||
UsageLimit int `json:"usageLimit" sortable:"true"`
|
||||
UsageCount int `json:"usageCount" sortable:"true"`
|
||||
EmailDomain *string `json:"emailDomain"`
|
||||
UserGroups []model.UserGroup `gorm:"many2many:signup_tokens_user_groups;"`
|
||||
}
|
||||
|
||||
func (st *SignupToken) IsExpired() bool {
|
||||
@@ -29,3 +31,24 @@ func (st *SignupToken) IsUsageLimitReached() bool {
|
||||
func (st *SignupToken) IsValid() bool {
|
||||
return !st.IsExpired() && !st.IsUsageLimitReached()
|
||||
}
|
||||
|
||||
// HasEmailDomainRestriction reports whether the token limits sign-ups to a specific email domain
|
||||
func (st *SignupToken) HasEmailDomainRestriction() bool {
|
||||
return st.EmailDomain != nil && *st.EmailDomain != ""
|
||||
}
|
||||
|
||||
// EmailMatchesDomain reports whether the given email address is allowed by the token's domain restriction
|
||||
// It returns true when the token has no restriction
|
||||
// The comparison is case-insensitive
|
||||
func (st *SignupToken) EmailMatchesDomain(email string) bool {
|
||||
if !st.HasEmailDomainRestriction() {
|
||||
return true
|
||||
}
|
||||
|
||||
at := strings.LastIndexByte(email, '@')
|
||||
if at < 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(email[at+1:], *st.EmailDomain)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package usersignup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func TestSignupTokenEmailMatchesDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
emailDomain *string
|
||||
email string
|
||||
want bool
|
||||
}{
|
||||
{name: "no restriction allows any email", emailDomain: nil, email: "user@anything.com", want: true},
|
||||
{name: "empty restriction allows any email", emailDomain: strPtr(""), email: "user@anything.com", want: true},
|
||||
{name: "matching domain", emailDomain: strPtr("example.com"), email: "user@example.com", want: true},
|
||||
{name: "matching domain case-insensitive", emailDomain: strPtr("example.com"), email: "User@Example.COM", want: true},
|
||||
{name: "non-matching domain", emailDomain: strPtr("example.com"), email: "user@other.com", want: false},
|
||||
{name: "subdomain does not match", emailDomain: strPtr("example.com"), email: "user@mail.example.com", want: false},
|
||||
{name: "domain suffix does not match", emailDomain: strPtr("example.com"), email: "user@notexample.com", want: false},
|
||||
{name: "missing @ with restriction", emailDomain: strPtr("example.com"), email: "userexample.com", want: false},
|
||||
{name: "empty email with restriction", emailDomain: strPtr("example.com"), email: "", want: false},
|
||||
{name: "plus addressing still matches", emailDomain: strPtr("example.com"), email: "user+tag@example.com", want: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
st := &SignupToken{EmailDomain: tc.emailDomain}
|
||||
got := st.EmailMatchesDomain(tc.email)
|
||||
if got != tc.want {
|
||||
t.Errorf("EmailMatchesDomain(%q) with domain %v = %v, want %v", tc.email, tc.emailDomain, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth, signupRate
|
||||
apiGroup.GET("/signup-tokens", adminAuth, m.handler.listSignupTokens)
|
||||
apiGroup.DELETE("/signup-tokens/:id", adminAuth, m.handler.deleteSignupToken)
|
||||
apiGroup.POST("/signup", signupRateLimit, m.handler.signup)
|
||||
apiGroup.GET("/signup/token/:token", signupRateLimit, m.handler.signupTokenInfo)
|
||||
apiGroup.GET("/signup/setup", m.handler.checkInitialAdminSetupAvailable)
|
||||
apiGroup.POST("/signup/setup", m.handler.signUpInitialAdmin)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,16 @@ func (s *Service) SignUp(ctx context.Context, signupData signUpDto, ipAddress, u
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
}
|
||||
|
||||
if signupToken.HasEmailDomainRestriction() {
|
||||
email := ""
|
||||
if signupData.Email != nil {
|
||||
email = *signupData.Email
|
||||
}
|
||||
if !signupToken.EmailMatchesDomain(email) {
|
||||
return model.User{}, "", &common.EmailDomainNotAllowedError{Domain: *signupToken.EmailDomain}
|
||||
}
|
||||
}
|
||||
|
||||
for _, group := range signupToken.UserGroups {
|
||||
userGroupIDs = append(userGroupIDs, group.ID)
|
||||
}
|
||||
@@ -190,8 +200,26 @@ func (s *Service) DeleteSignupToken(ctx context.Context, tokenID string) error {
|
||||
return s.db.WithContext(ctx).Delete(&SignupToken{}, "id = ?", tokenID).Error
|
||||
}
|
||||
|
||||
func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (SignupToken, error) {
|
||||
signupToken, err := newSignupToken(ttl, usageLimit)
|
||||
// GetSignupTokenInfo returns a signup token by its token string.
|
||||
// It's used to expose the limited, public metadata (such as the required email domain) needed to render the signup form.
|
||||
func (s *Service) GetSignupTokenInfo(ctx context.Context, token string) (SignupToken, error) {
|
||||
var signupToken SignupToken
|
||||
err := s.db.
|
||||
WithContext(ctx).
|
||||
Where("token = ?", token).
|
||||
First(&signupToken).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return SignupToken{}, &common.TokenInvalidOrExpiredError{}
|
||||
} else if err != nil {
|
||||
return SignupToken{}, err
|
||||
}
|
||||
|
||||
return signupToken, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string, emailDomain *string) (SignupToken, error) {
|
||||
signupToken, err := newSignupToken(ttl, usageLimit, emailDomain)
|
||||
if err != nil {
|
||||
return SignupToken{}, err
|
||||
}
|
||||
@@ -214,7 +242,7 @@ func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usag
|
||||
return *signupToken, nil
|
||||
}
|
||||
|
||||
func newSignupToken(ttl time.Duration, usageLimit int) (*SignupToken, error) {
|
||||
func newSignupToken(ttl time.Duration, usageLimit int, emailDomain *string) (*SignupToken, error) {
|
||||
// Generate a random token
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
||||
if err != nil {
|
||||
@@ -223,10 +251,11 @@ func newSignupToken(ttl time.Duration, usageLimit int) (*SignupToken, error) {
|
||||
|
||||
now := time.Now().Round(time.Second)
|
||||
token := &SignupToken{
|
||||
Token: randomString,
|
||||
ExpiresAt: datatype.DateTime(now.Add(ttl)),
|
||||
UsageLimit: usageLimit,
|
||||
UsageCount: 0,
|
||||
Token: randomString,
|
||||
ExpiresAt: datatype.DateTime(now.Add(ttl)),
|
||||
UsageLimit: usageLimit,
|
||||
UsageCount: 0,
|
||||
EmailDomain: emailDomain,
|
||||
}
|
||||
|
||||
return token, nil
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
ALTER TABLE signup_tokens DROP COLUMN email_domain;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE signup_tokens ADD COLUMN email_domain TEXT;
|
||||
@@ -0,0 +1,5 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
ALTER TABLE signup_tokens DROP COLUMN email_domain;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,5 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
ALTER TABLE signup_tokens ADD COLUMN email_domain TEXT;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
Reference in New Issue
Block a user