feat: restrict signup invite links to a specific email domain

This commit is contained in:
ItalyPaleAle
2026-07-16 06:44:59 -07:00
parent 525946e94e
commit fdfaac8a35
25 changed files with 442 additions and 35 deletions
+14 -7
View File
@@ -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"`
}
+23 -1
View File
@@ -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
+28 -5
View File
@@ -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)
}
})
}
}
+1
View File
@@ -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)
}
+36 -7
View File
@@ -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