From d1618b74710046271d2fb2c8b3fef3745f91505e Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 6 Jan 2025 16:47:41 +0100 Subject: [PATCH] feat: allow sign in with email --- .../internal/bootstrap/router_bootstrap.go | 2 +- .../internal/controller/user_controller.go | 21 ++++++- backend/internal/dto/app_config_dto.go | 25 ++++---- backend/internal/dto/user_dto.go | 4 ++ backend/internal/model/app_config.go | 9 +-- .../internal/service/app_config_service.go | 13 ++++ .../service/email_service_templates.go | 15 ++++- backend/internal/service/user_service.go | 50 ++++++++++++++-- .../utils/email/email_service_templates.go | 2 - .../components/style_html.tmpl | 15 +++++ .../login-with-new-device_html.tmpl | 2 +- .../email-templates/one-time-access_html.tmpl | 17 ++++++ .../email-templates/one-time-access_text.tmpl | 8 +++ .../resources/email-templates/test_html.tmpl | 2 +- .../src/lib/components/login-wrapper.svelte | 33 +++++++++-- .../src/lib/services/app-config-service.ts | 19 +++--- frontend/src/lib/services/user-service.ts | 4 ++ .../lib/types/application-configuration.ts | 1 + frontend/src/routes/authorize/+page.svelte | 4 +- frontend/src/routes/login/+page.svelte | 6 +- ...login-logo-error-success-indicator.svelte} | 17 ++++-- frontend/src/routes/login/email/+page.svelte | 59 +++++++++++++++++++ .../application-configuration/+page.svelte | 10 +++- .../forms/app-config-email-form.svelte | 1 - .../forms/app-config-general-form.svelte | 15 +++-- .../oidc-clients/oidc-client-form.svelte | 1 - 26 files changed, 293 insertions(+), 62 deletions(-) create mode 100644 backend/resources/email-templates/one-time-access_html.tmpl create mode 100644 backend/resources/email-templates/one-time-access_text.tmpl rename frontend/src/routes/login/components/{login-logo-error-indicator.svelte => login-logo-error-success-indicator.svelte} (57%) create mode 100644 frontend/src/routes/login/email/+page.svelte diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index ba47459..bd3f9a9 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -37,7 +37,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) { auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService) jwtService := service.NewJwtService(appConfigService) webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService) - userService := service.NewUserService(db, jwtService, auditLogService) + userService := service.NewUserService(db, jwtService, auditLogService, emailService) customClaimService := service.NewCustomClaimService(db) oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService) testService := service.NewTestService(db, appConfigService) diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index 54df2ae..7b1da97 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -29,6 +29,7 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler) group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler) group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler) + group.POST("/one-time-access-email", uc.requestOneTimeAccessEmailHandler) } type UserController struct { @@ -141,7 +142,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) { return } - token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt, c.ClientIP(), c.Request.UserAgent()) + token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt) if err != nil { c.Error(err) return @@ -150,8 +151,24 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"token": token}) } +func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) { + var input dto.OneTimeAccessEmailDto + if err := c.ShouldBindJSON(&input); err != nil { + c.Error(err) + return + } + + err := uc.UserService.RequestOneTimeAccessEmail(input.Email) + if err != nil { + c.Error(err) + return + } + + c.Status(http.StatusNoContent) +} + func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { - user, token, err := uc.UserService.ExchangeOneTimeAccessToken(c.Param("token")) + user, token, err := uc.UserService.ExchangeOneTimeAccessToken(c.Param("token"), c.ClientIP(), c.Request.UserAgent()) if err != nil { c.Error(err) return diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go index a10e4d7..5ee5de2 100644 --- a/backend/internal/dto/app_config_dto.go +++ b/backend/internal/dto/app_config_dto.go @@ -12,16 +12,17 @@ type AppConfigVariableDto struct { } type AppConfigUpdateDto struct { - AppName string `json:"appName" binding:"required,min=1,max=30"` - SessionDuration string `json:"sessionDuration" binding:"required"` - EmailsVerified string `json:"emailsVerified" binding:"required"` - AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` - EmailEnabled string `json:"emailEnabled" binding:"required"` - SmtHost string `json:"smtpHost"` - SmtpPort string `json:"smtpPort"` - SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` - SmtpUser string `json:"smtpUser"` - SmtpPassword string `json:"smtpPassword"` - SmtpTls string `json:"smtpTls"` - SmtpSkipCertVerify string `json:"smtpSkipCertVerify"` + AppName string `json:"appName" binding:"required,min=1,max=30"` + SessionDuration string `json:"sessionDuration" binding:"required"` + EmailsVerified string `json:"emailsVerified" binding:"required"` + AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` + EmailOneTimeAccessEnabled string `json:"emailOneTimeAccessEnabled" binding:"required"` + EmailEnabled string `json:"emailEnabled" binding:"required"` + SmtHost string `json:"smtpHost"` + SmtpPort string `json:"smtpPort"` + SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` + SmtpUser string `json:"smtpUser"` + SmtpPassword string `json:"smtpPassword"` + SmtpTls string `json:"smtpTls"` + SmtpSkipCertVerify string `json:"smtpSkipCertVerify"` } diff --git a/backend/internal/dto/user_dto.go b/backend/internal/dto/user_dto.go index 7351bd1..7279b80 100644 --- a/backend/internal/dto/user_dto.go +++ b/backend/internal/dto/user_dto.go @@ -24,3 +24,7 @@ type OneTimeAccessTokenCreateDto struct { UserID string `json:"userId" binding:"required"` ExpiresAt time.Time `json:"expiresAt" binding:"required"` } + +type OneTimeAccessEmailDto struct { + Email string `json:"email" binding:"required,email"` +} diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go index b8e4471..7951cc4 100644 --- a/backend/internal/model/app_config.go +++ b/backend/internal/model/app_config.go @@ -10,10 +10,11 @@ type AppConfigVariable struct { } type AppConfig struct { - AppName AppConfigVariable - SessionDuration AppConfigVariable - EmailsVerified AppConfigVariable - AllowOwnAccountEdit AppConfigVariable + AppName AppConfigVariable + SessionDuration AppConfigVariable + EmailsVerified AppConfigVariable + AllowOwnAccountEdit AppConfigVariable + EmailOneTimeAccessEnabled AppConfigVariable BackgroundImageType AppConfigVariable LogoLightImageType AppConfigVariable diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go index 84f2dfd..3b91f15 100644 --- a/backend/internal/service/app_config_service.go +++ b/backend/internal/service/app_config_service.go @@ -52,6 +52,12 @@ var defaultDbConfig = model.AppConfig{ IsPublic: true, DefaultValue: "true", }, + EmailOneTimeAccessEnabled: model.AppConfigVariable{ + Key: "emailOneTimeAccessEnabled", + Type: "bool", + IsPublic: true, + DefaultValue: "false", + }, BackgroundImageType: model.AppConfigVariable{ Key: "backgroundImageType", Type: "string", @@ -119,6 +125,13 @@ func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]mode key := field.Tag.Get("json") value := rv.FieldByName(field.Name).String() + // If the emailEnabled is set to false, disable the emailOneTimeAccessEnabled + if key == s.DbConfig.EmailOneTimeAccessEnabled.Key { + if rv.FieldByName("EmailEnabled").String() == "false" { + value = "false" + } + } + var appConfigVariable model.AppConfigVariable if err := tx.First(&appConfigVariable, "key = ? AND is_internal = false", key).Error; err != nil { tx.Rollback() diff --git a/backend/internal/service/email_service_templates.go b/backend/internal/service/email_service_templates.go index e6d0fb8..1f0962c 100644 --- a/backend/internal/service/email_service_templates.go +++ b/backend/internal/service/email_service_templates.go @@ -9,7 +9,7 @@ import ( /** How to add new template: - pick unique and descriptive template ${name} (for example "login-with-new-device") -- in backend/email-templates/ create "${name}_html.tmpl" and "${name}_text.tmpl" +- in backend/resources/email-templates/ create "${name}_html.tmpl" and "${name}_text.tmpl" - create xxxxTemplate and xxxxTemplateData (for example NewLoginTemplate and NewLoginTemplateData) - Path *must* be ${name} - add xxxTemplate.Path to "emailTemplatePaths" at the end @@ -27,6 +27,13 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{ }, } +var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{ + Path: "one-time-access", + Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string { + return "One time access" + }, +} + var TestTemplate = email.Template[struct{}]{ Path: "test", Title: func(data *email.TemplateData[struct{}]) string { @@ -42,5 +49,9 @@ type NewLoginTemplateData struct { DateTime time.Time } +type OneTimeAccessTemplateData = struct { + Link string +} + // this is list of all template paths used for preloading templates -var emailTemplatesPaths = []string{NewLoginTemplate.Path, TestTemplate.Path} +var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 7d1007e..2edf9cd 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -2,12 +2,15 @@ package service import ( "errors" + "fmt" "github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/model" "github.com/stonith404/pocket-id/backend/internal/model/types" "github.com/stonith404/pocket-id/backend/internal/utils" + "github.com/stonith404/pocket-id/backend/internal/utils/email" "gorm.io/gorm" + "log" "time" ) @@ -15,10 +18,11 @@ type UserService struct { db *gorm.DB jwtService *JwtService auditLogService *AuditLogService + emailService *EmailService } -func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService) *UserService { - return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService} +func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService) *UserService { + return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService} } func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) { @@ -89,7 +93,39 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u return user, nil } -func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time, ipAddress, userAgent string) (string, error) { +func (s *UserService) RequestOneTimeAccessEmail(emailAddress string) error { + var user model.User + if err := s.db.Where("email = ?", emailAddress).First(&user).Error; err != nil { + // Do not return error if user not found to prevent email enumeration + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } else { + return err + } + } + + oneTimeAccessToken, err := s.CreateOneTimeAccessToken(user.ID, time.Now().Add(time.Hour)) + if err != nil { + return err + } + link := fmt.Sprintf("%s/login/%s", common.EnvConfig.AppURL, oneTimeAccessToken) + + go func() { + err := SendEmail(s.emailService, email.Address{ + Name: user.Username, + Email: user.Email, + }, OneTimeAccessTemplate, &OneTimeAccessTemplateData{ + Link: link, + }) + if err != nil { + log.Printf("Failed to send email to '%s': %v\n", user.Email, err) + } + }() + + return nil +} + +func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) { randomString, err := utils.GenerateRandomAlphanumericString(16) if err != nil { return "", err @@ -105,12 +141,10 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim return "", err } - s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, userID, model.AuditLogData{}) - return oneTimeAccessToken.Token, nil } -func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) { +func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAgent string) (model.User, string, error) { var oneTimeAccessToken model.OneTimeAccessToken if err := s.db.Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -127,6 +161,10 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, stri return model.User{}, "", err } + if ipAddress != "" && userAgent != "" { + s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}) + } + return oneTimeAccessToken.User, accessToken, nil } diff --git a/backend/internal/utils/email/email_service_templates.go b/backend/internal/utils/email/email_service_templates.go index 2701a03..d477272 100644 --- a/backend/internal/utils/email/email_service_templates.go +++ b/backend/internal/utils/email/email_service_templates.go @@ -9,8 +9,6 @@ import ( ttemplate "text/template" ) -const templateComponentsDir = "components" - type Template[V any] struct { Path string Title func(data *TemplateData[V]) string diff --git a/backend/resources/email-templates/components/style_html.tmpl b/backend/resources/email-templates/components/style_html.tmpl index d378806..f907dbe 100644 --- a/backend/resources/email-templates/components/style_html.tmpl +++ b/backend/resources/email-templates/components/style_html.tmpl @@ -76,5 +76,20 @@ font-size: 1rem; line-height: 1.5; } + .button { + border-radius: 0.375rem; + font-size: 1rem; + font-weight: 500; + background-color: #000000; + color: #ffffff; + padding: 0.7rem 1.5rem; + outline: none; + border: none; + text-decoration: none; + } + .button-container { + text-align: center; + margin-top: 24px; + } {{ end }} diff --git a/backend/resources/email-templates/login-with-new-device_html.tmpl b/backend/resources/email-templates/login-with-new-device_html.tmpl index c911e83..6c2c811 100644 --- a/backend/resources/email-templates/login-with-new-device_html.tmpl +++ b/backend/resources/email-templates/login-with-new-device_html.tmpl @@ -1,7 +1,7 @@ {{ define "base" }}
Warning
diff --git a/backend/resources/email-templates/one-time-access_html.tmpl b/backend/resources/email-templates/one-time-access_html.tmpl new file mode 100644 index 0000000..f284769 --- /dev/null +++ b/backend/resources/email-templates/one-time-access_html.tmpl @@ -0,0 +1,17 @@ +{{ define "base" }} +
+ +
+
+

One-Time Access

+

+ Click the button below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes. +

+
+ Sign In +
+
+{{ end -}} \ No newline at end of file diff --git a/backend/resources/email-templates/one-time-access_text.tmpl b/backend/resources/email-templates/one-time-access_text.tmpl new file mode 100644 index 0000000..dbf1413 --- /dev/null +++ b/backend/resources/email-templates/one-time-access_text.tmpl @@ -0,0 +1,8 @@ +{{ define "base" -}} +One-Time Access +==================== + +Click the link below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes. + +{{ .Data.Link }} +{{ end -}} diff --git a/backend/resources/email-templates/test_html.tmpl b/backend/resources/email-templates/test_html.tmpl index c984000..73c8360 100644 --- a/backend/resources/email-templates/test_html.tmpl +++ b/backend/resources/email-templates/test_html.tmpl @@ -1,7 +1,7 @@ {{ define "base" -}}
diff --git a/frontend/src/lib/components/login-wrapper.svelte b/frontend/src/lib/components/login-wrapper.svelte index 5b0beb3..f7e0079 100644 --- a/frontend/src/lib/components/login-wrapper.svelte +++ b/frontend/src/lib/components/login-wrapper.svelte @@ -2,22 +2,37 @@ import { browser } from '$app/environment'; import { browserSupportsWebAuthn } from '@simplewebauthn/browser'; import type { Snippet } from 'svelte'; + import { Button } from './ui/button'; import * as Card from './ui/card'; import WebAuthnUnsupported from './web-authn-unsupported.svelte'; let { - children + children, + showEmailOneTimeAccessButton = false }: { children: Snippet; + showEmailOneTimeAccessButton?: boolean; } = $props(); +