feat: allow sign in with email (#100)

This commit is contained in:
Elias Schneider
2025-01-19 15:30:31 +01:00
committed by GitHub
parent e284e352e2
commit 06b90eddd6
42 changed files with 422 additions and 145 deletions

View File

@@ -2,7 +2,6 @@ package bootstrap
import (
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/stonith404/pocket-id/backend/internal/job"
"github.com/stonith404/pocket-id/backend/internal/service"
)
@@ -11,6 +10,5 @@ func Bootstrap() {
appConfigService := service.NewAppConfigService(db)
initApplicationImages()
job.RegisterJobs(db)
initRouter(db, appConfigService)
}

View File

@@ -38,21 +38,25 @@ 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)
userGroupService := service.NewUserGroupService(db)
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
// Setup global middleware
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
r.Use(rateLimitMiddleware.Add(rate.Every(time.Second), 60))
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
job.RegisterLdapJobs(ldapService, appConfigService)
job.RegisterDbCleanupJobs(db)
// Initialize middleware
// Initialize middleware for specific routes
jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()

View File

@@ -97,7 +97,7 @@ func (e *MissingPermissionError) HttpStatusCode() int { return http.StatusForbid
type TooManyRequestsError struct{}
func (e *TooManyRequestsError) Error() string {
return "Too many requests. Please wait a while before trying again."
return "Too many requests"
}
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }

View File

@@ -30,6 +30,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", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler)
}
type UserController struct {
@@ -145,7 +146,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
@@ -154,8 +155,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, input.RedirectPath)
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

View File

@@ -16,7 +16,6 @@ type AppConfigUpdateDto struct {
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"`
@@ -38,4 +37,6 @@ type AppConfigUpdateDto struct {
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
EmailOneTimeAccessEnabled string `json:"emailOneTimeAccessEnabled" binding:"required"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
}

View File

@@ -26,3 +26,8 @@ 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"`
RedirectPath string `json:"redirectPath"`
}

View File

@@ -10,7 +10,7 @@ import (
"time"
)
func RegisterJobs(db *gorm.DB) {
func RegisterDbCleanupJobs(db *gorm.DB) {
scheduler, err := gocron.NewScheduler()
if err != nil {
log.Fatalf("Failed to create a new scheduler: %s", err)

View File

@@ -16,8 +16,12 @@ func NewRateLimitMiddleware() *RateLimitMiddleware {
}
func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
// Map to store the rate limiters per IP
var clients = make(map[string]*client)
var mu sync.Mutex
// Start the cleanup routine
go cleanupClients()
go cleanupClients(&mu, clients)
return func(c *gin.Context) {
ip := c.ClientIP()
@@ -29,7 +33,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
return
}
limiter := getLimiter(ip, limit, burst)
limiter := getLimiter(ip, limit, burst, &mu, clients)
if !limiter.Allow() {
c.Error(&common.TooManyRequestsError{})
c.Abort()
@@ -45,12 +49,8 @@ type client struct {
lastSeen time.Time
}
// Map to store the rate limiters per IP
var clients = make(map[string]*client)
var mu sync.Mutex
// Cleanup routine to remove stale clients that haven't been seen for a while
func cleanupClients() {
func cleanupClients(mu *sync.Mutex, clients map[string]*client) {
for {
time.Sleep(time.Minute)
mu.Lock()
@@ -64,7 +64,7 @@ func cleanupClients() {
}
// getLimiter retrieves the rate limiter for a given IP address, creating one if it doesn't exist
func getLimiter(ip string, limit rate.Limit, burst int) *rate.Limiter {
func getLimiter(ip string, limit rate.Limit, burst int, mu *sync.Mutex, clients map[string]*client) *rate.Limiter {
mu.Lock()
defer mu.Unlock()

View File

@@ -20,7 +20,6 @@ type AppConfig struct {
LogoLightImageType AppConfigVariable
LogoDarkImageType AppConfigVariable
// Email
EmailEnabled AppConfigVariable
SmtpHost AppConfigVariable
SmtpPort AppConfigVariable
SmtpFrom AppConfigVariable
@@ -28,6 +27,8 @@ type AppConfig struct {
SmtpPassword AppConfigVariable
SmtpTls AppConfigVariable
SmtpSkipCertVerify AppConfigVariable
EmailLoginNotificationEnabled AppConfigVariable
EmailOneTimeAccessEnabled AppConfigVariable
// LDAP
LdapEnabled AppConfigVariable
LdapUrl AppConfigVariable

View File

@@ -73,12 +73,7 @@ var defaultDbConfig = model.AppConfig{
IsInternal: true,
DefaultValue: "svg",
},
// Email
EmailEnabled: model.AppConfigVariable{
Key: "emailEnabled",
Type: "bool",
DefaultValue: "false",
},
// Email
SmtpHost: model.AppConfigVariable{
Key: "smtpHost",
Type: "string",
@@ -109,6 +104,17 @@ var defaultDbConfig = model.AppConfig{
Type: "bool",
DefaultValue: "false",
},
EmailLoginNotificationEnabled: model.AppConfigVariable{
Key: "emailLoginNotificationEnabled",
Type: "bool",
DefaultValue: "false",
},
EmailOneTimeAccessEnabled: model.AppConfigVariable{
Key: "emailOneTimeAccessEnabled",
Type: "bool",
IsPublic: true,
DefaultValue: "false",
},
// LDAP
LdapEnabled: model.AppConfigVariable{
Key: "ldapEnabled",
@@ -182,6 +188,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()

View File

@@ -58,8 +58,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
return createdAuditLog
}
// If the user hasn't logged in from the same device before, send an email
if count <= 1 {
// If the user hasn't logged in from the same device before and email notifications are enabled, send an email
if s.appConfigService.DbConfig.EmailLoginNotificationEnabled.Value == "true" && count <= 1 {
go func() {
var user model.User
s.db.Where("id = ?", userID).First(&user)

View File

@@ -3,7 +3,6 @@ package service
import (
"bytes"
"crypto/tls"
"errors"
"fmt"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model"
@@ -16,8 +15,13 @@ import (
"net/smtp"
"net/textproto"
ttemplate "text/template"
"time"
)
var netDialer = &net.Dialer{
Timeout: 3 * time.Second,
}
type EmailService struct {
appConfigService *AppConfigService
db *gorm.DB
@@ -58,11 +62,6 @@ func (srv *EmailService) SendTestEmail(recipientUserId string) error {
}
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
// Check if SMTP settings are set
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
return errors.New("email not enabled")
}
data := &email.TemplateData[V]{
AppName: srv.appConfigService.DbConfig.AppName.Value,
LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo",
@@ -112,11 +111,13 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
tlsConfig,
)
}
defer client.Quit()
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
defer client.Close()
smtpUser := srv.appConfigService.DbConfig.SmtpUser.Value
smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value
@@ -141,7 +142,11 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
}
func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := tls.Dial("tcp", serverAddr, tlsConfig)
tlsDialer := &tls.Dialer{
NetDialer: netDialer,
Config: tlsConfig,
}
conn, err := tlsDialer.Dial("tcp", serverAddr)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
@@ -156,7 +161,7 @@ func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string,
}
func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := net.Dial("tcp", serverAddr)
conn, err := netDialer.Dial("tcp", serverAddr)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}

View File

@@ -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}

View File

@@ -2,12 +2,17 @@ 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"
"net/url"
"strings"
"time"
)
@@ -15,10 +20,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, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
@@ -99,7 +105,46 @@ 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, redirectPath 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)
// Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath)
link = fmt.Sprintf("%s?redirect=%s", link, encodedRedirectPath)
}
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
@@ -115,12 +160,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) {
@@ -137,6 +180,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
}

View File

@@ -9,8 +9,6 @@ import (
ttemplate "text/template"
)
const templateComponentsDir = "components"
type Template[V any] struct {
Path string
Title func(data *TemplateData[V]) string

View File

@@ -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;
}
</style>
{{ end }}

View File

@@ -1,7 +1,7 @@
{{ define "base" }}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="Pocket ID"/>
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1>
</div>
<div class="warning">Warning</div>

View File

@@ -0,0 +1,17 @@
{{ define "base" }}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1>
</div>
</div>
<div class="content">
<h2>One-Time Access</h2>
<p class="message">
Click the button below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes.
</p>
<div class="button-container">
<a class="button" href="{{ .Data.Link }}" class="button">Sign In</a>
</div>
</div>
{{ end -}}

View File

@@ -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 -}}

View File

@@ -1,7 +1,7 @@
{{ define "base" -}}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="Pocket ID"/>
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1>
</div>
</div>

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailEnabled' WHERE key = 'emailLoginNotificationEnabled';

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailLoginNotificationEnabled' WHERE key = 'emailEnabled';

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailEnabled' WHERE key = 'emailLoginNotificationEnabled';

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailLoginNotificationEnabled' WHERE key = 'emailEnabled';

View File

@@ -2,22 +2,38 @@
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';
import { page } from '$app/stores';
let {
children
children,
showEmailOneTimeAccessButton = false
}: {
children: Snippet;
showEmailOneTimeAccessButton?: boolean;
} = $props();
</script>
<!-- Desktop -->
<div class="hidden h-screen items-center text-center lg:flex">
<div class="min-w-[650px] p-16">
<div class="h-full min-w-[650px] p-16 {showEmailOneTimeAccessButton ? 'pb-0' : ''}">
{#if browser && !browserSupportsWebAuthn()}
<WebAuthnUnsupported />
{:else}
{@render children()}
<div class="flex h-full flex-col">
<div class="flex flex-grow flex-col items-center justify-center">
{@render children()}
</div>
{#if showEmailOneTimeAccessButton}
<div class="mb-4 flex justify-center">
<Button href="/login/email?redirect={encodeURIComponent($page.url.pathname + $page.url.search)}" variant="link" class="text-muted-foreground text-xs">
Don't have access to your passkey?
</Button>
</div>
{/if}
</div>
{/if}
</div>
<img
@@ -27,15 +43,25 @@
/>
</div>
<!-- Mobile -->
<div
class="flex h-screen items-center justify-center bg-[url('/api/application-configuration/background-image')] bg-cover bg-center text-center lg:hidden"
>
<Card.Root class="mx-3">
<Card.CardContent class="px-4 py-10 sm:p-10">
<Card.CardContent
class="px-4 py-10 sm:p-10 {showEmailOneTimeAccessButton ? 'pb-3 sm:pb-3' : ''}"
>
{#if browser && !browserSupportsWebAuthn()}
<WebAuthnUnsupported />
{:else}
{@render children()}
{#if showEmailOneTimeAccessButton}
<div class="mt-5">
<Button href="/login/email?redirect={encodeURIComponent($page.url.pathname + $page.url.search)}" variant="link" class="text-muted-foreground text-xs">
Don't have access to your passkey?
</Button>
</div>
{/if}
{/if}
</Card.CardContent>
</Card.Root>

View File

@@ -11,13 +11,7 @@ export default class AppConfigService extends APIService {
}
const { data } = await this.api.get<AppConfigRawResponse>(url);
const appConfig: Partial<AllAppConfig> = {};
data.forEach(({ key, value }) => {
(appConfig as any)[key] = this.parseValue(value);
});
return appConfig as AllAppConfig;
return this.parseConfigList(data);
}
async update(appConfig: AllAppConfig) {
@@ -27,7 +21,7 @@ export default class AppConfigService extends APIService {
(appConfigConvertedToString as any)[key] = (appConfig as any)[key].toString();
}
const res = await this.api.put('/application-configuration', appConfigConvertedToString);
return res.data as AllAppConfig;
return this.parseConfigList(res.data);
}
async updateFavicon(favicon: File) {
@@ -76,6 +70,15 @@ export default class AppConfigService extends APIService {
};
}
private parseConfigList(data: AppConfigRawResponse) {
const appConfig: Partial<AllAppConfig> = {};
data.forEach(({ key, value }) => {
(appConfig as any)[key] = this.parseValue(value);
});
return appConfig as AllAppConfig;
}
private parseValue(value: string) {
if (value === 'true') {
return true;

View File

@@ -51,4 +51,8 @@ export default class UserService extends APIService {
const res = await this.api.post(`/one-time-access-token/${token}`);
return res.data as User;
}
async requestOneTimeAccessEmail(email: string, redirectPath?: string) {
await this.api.post('/one-time-access-email', { email, redirectPath });
}
}

View File

@@ -1,6 +1,7 @@
export type AppConfig = {
appName: string;
allowOwnAccountEdit: boolean;
emailOneTimeAccessEnabled: boolean
};
export type AllAppConfig = AppConfig & {
@@ -8,7 +9,6 @@ export type AllAppConfig = AppConfig & {
sessionDuration: number;
emailsVerified: boolean;
// Email
emailEnabled: boolean;
smtpHost: string;
smtpPort: number;
smtpFrom: string;
@@ -16,6 +16,7 @@ export type AllAppConfig = AppConfig & {
smtpPassword: string;
smtpTls: boolean;
smtpSkipCertVerify: boolean;
emailLoginNotificationEnabled: boolean;
// LDAP
ldapEnabled: boolean;
ldapUrl: string;

View File

@@ -2,10 +2,19 @@ import { WebAuthnError } from '@simplewebauthn/browser';
import { AxiosError } from 'axios';
import { toast } from 'svelte-sonner';
export function axiosErrorToast(e: unknown, message: string = 'An unknown error occurred') {
export function getAxiosErrorMessage(
e: unknown,
defaultMessage: string = 'An unknown error occurred'
) {
let message = defaultMessage;
if (e instanceof AxiosError) {
message = e.response?.data.error || message;
}
return message;
}
export function axiosErrorToast(e: unknown, defaultMessage: string = 'An unknown error occurred') {
const message = getAxiosErrorMessage(e, defaultMessage);
toast.error(message);
}

View File

@@ -91,7 +91,7 @@
{#if client == null}
<p>Client not found</p>
{:else}
<SignInWrapper>
<SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}>
<ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
{#if errorMessage}
@@ -136,7 +136,7 @@
</Card.Root>
</div>
{/if}
<div class="flex justify-center gap-2">
<div class="flex w-full justify-stretch gap-2">
<Button onclick={() => history.back()} class="w-full" variant="secondary">Cancel</Button>
{#if !errorMessage}
<Button

View File

@@ -8,7 +8,7 @@
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser';
import { fade } from 'svelte/transition';
import LoginLogoErrorIndicator from './components/login-logo-error-indicator.svelte';
import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte';
const webauthnService = new WebAuthnService();
let isLoading = $state(false);
@@ -35,9 +35,9 @@
<title>Sign In</title>
</svelte:head>
<SignInWrapper>
<SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}>
<div class="flex justify-center">
<LoginLogoErrorIndicator error={!!error} />
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
Sign in to {$appConfigStore.appName}

View File

@@ -1,7 +1,8 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
export const load: PageServerLoad = async ({ params, url }) => {
return {
token: params.token
token: params.token,
redirect: url.searchParams.get('redirect') || '/settings'
};
};

View File

@@ -1,51 +1,69 @@
<script lang="ts">
import { goto } from '$app/navigation';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store.js';
import userStore from '$lib/stores/user-store.js';
import type { User } from '$lib/types/user.type.js';
import { axiosErrorToast } from '$lib/utils/error-util';
import { getAxiosErrorMessage } from '$lib/utils/error-util';
import { onMount } from 'svelte';
import LoginLogoErrorSuccessIndicator from '../components/login-logo-error-success-indicator.svelte';
let { data } = $props();
let isLoading = $state(false);
let error: string | undefined = $state();
const skipPage = data.redirect !== '/settings';
const userService = new UserService();
async function authenticate() {
isLoading = true;
userService
.exchangeOneTimeAccessToken(data.token)
.then((user: User) => {
userStore.setUser(user);
goto('/settings');
})
.catch(axiosErrorToast);
try {
const user = await userService.exchangeOneTimeAccessToken(data.token);
userStore.setUser(user);
try {
goto(data.redirect);
} catch (e) {
error = 'Invalid redirect URL';
}
} catch (e) {
error = getAxiosErrorMessage(e);
}
isLoading = false;
}
onMount(() => {
if (skipPage) {
authenticate();
}
});
</script>
<SignInWrapper>
<div class="flex justify-center">
<div class="bg-muted rounded-2xl p-3">
<Logo class="h-10 w-10" />
</div>
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-4xl font-bold">
{data.token === 'setup' ? `${$appConfigStore.appName} Setup` : 'One Time Access'}
</h1>
<p class="text-muted-foreground mt-2">
{#if data.token === 'setup'}
You're about to sign in to the initial admin account. Anyone with this link can access the
account until a passkey is added. Please set up a passkey as soon as possible to prevent
unauthorized access.
{:else}
You've been granted one-time access to your {$appConfigStore.appName} account. Please note that
if you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
you'll need to request a new link.
{/if}
</p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
{#if error}
<p class="text-muted-foreground mt-2">
{error}. Please try again.
</p>
{:else if !skipPage}
<p class="text-muted-foreground mt-2">
{#if data.token === 'setup'}
You're about to sign in to the initial admin account. Anyone with this link can access the
account until a passkey is added. Please set up a passkey as soon as possible to prevent
unauthorized access.
{:else}
You've been granted one-time access to your {$appConfigStore.appName} account. Please note that
if you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
you'll need to request a new link.
{/if}
</p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
{/if}
</SignInWrapper>

View File

@@ -1,22 +1,29 @@
<script lang="ts">
import Logo from '$lib/components/logo.svelte';
import CheckmarkAnimated from '$lib/icons/checkmark-animated.svelte';
import CrossAnimated from '$lib/icons/cross-animated.svelte';
import { fade } from 'svelte/transition';
const {
error
error,
success
}: {
error: boolean;
error?: boolean;
success?: boolean;
} = $props();
</script>
<div
class="rounded-2xl p-3 transition-[background-color] duration-300
{error ? 'bg-red-200' : 'bg-muted'}"
{error ? 'bg-red-200' : success ? 'bg-green-200' : 'bg-muted'}"
>
{#if error}
{#if error || success}
<div class="flex h-10 w-10 items-center justify-center">
<CrossAnimated class="h-5 w-5" />
{#if error}
<CrossAnimated class="h-5 w-5" />
{:else}
<CheckmarkAnimated class="h-5 w-5" />
{/if}
</div>
{:else}
<div in:fade={{ duration: 300 }}>

View File

@@ -0,0 +1,7 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
return {
redirect: url.searchParams.get('redirect') || undefined
};
};

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import { Button } from '$lib/components/ui/button';
import Input from '$lib/components/ui/input/input.svelte';
import UserService from '$lib/services/user-service';
import { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from '../components/login-logo-error-success-indicator.svelte';
const { data } = $props();
const userService = new UserService();
let email = $state('');
let isLoading = $state(false);
let error: string | undefined = $state(undefined);
let success = $state(false);
async function requestEmail() {
isLoading = true;
await userService
.requestOneTimeAccessEmail(email, data.redirect)
.then(() => (success = true))
.catch((e) => (error = e.response?.data.error || 'An unknown error occured'));
isLoading = false;
}
</script>
<svelte:head>
<title>Email One Time Access</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator {success} error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Email One Time Access</h1>
{#if error}
<p class="text-muted-foreground mt-2" in:fade>
{error}. Please try again.
</p>
<div class="mt-10 flex w-full justify-stretch gap-2">
<Button variant="secondary" class="w-full" href="/">Go back</Button>
<Button class="w-full" onclick={() => (error = undefined)}>Try again</Button>
</div>
{:else if success}
<p class="text-muted-foreground mt-2" in:fade>
An email has been sent to the provided email, if it exists in the system.
</p>
{:else}
<form onsubmit={requestEmail}>
<p class="text-muted-foreground mt-2" in:fade>
Enter your email to receive an email with a one time access link.
</p>
<Input id="Email" class="mt-7" placeholder="Your email" bind:value={email} />
<div class="mt-8 flex justify-stretch gap-2">
<Button variant="secondary" class="w-full" href="/">Go back</Button>
<Button class="w-full" type="submit" {isLoading}>Submit</Button>
</div>
</form>
{/if}
</SignInWrapper>

View File

@@ -16,7 +16,7 @@
const appConfigService = new AppConfigService();
async function updateAppConfig(updatedAppConfig: Partial<AllAppConfig>) {
await appConfigService
appConfig = await appConfigService
.update({
...appConfig,
...updatedAppConfig

View File

@@ -19,17 +19,17 @@
const appConfigService = new AppConfigService();
let isSendingTestEmail = $state(false);
let emailEnabled = $state(appConfig.emailEnabled);
const updatedAppConfig = {
emailEnabled: appConfig.emailEnabled,
smtpHost: appConfig.smtpHost,
smtpPort: appConfig.smtpPort,
smtpUser: appConfig.smtpUser,
smtpPassword: appConfig.smtpPassword,
smtpFrom: appConfig.smtpFrom,
smtpTls: appConfig.smtpTls,
smtpSkipCertVerify: appConfig.smtpSkipCertVerify
smtpSkipCertVerify: appConfig.smtpSkipCertVerify,
emailOneTimeAccessEnabled: appConfig.emailOneTimeAccessEnabled,
emailLoginNotificationEnabled: appConfig.emailLoginNotificationEnabled
};
const formSchema = z.object({
@@ -39,7 +39,9 @@
smtpPassword: z.string(),
smtpFrom: z.string().email(),
smtpTls: z.boolean(),
smtpSkipCertVerify: z.boolean()
smtpSkipCertVerify: z.boolean(),
emailOneTimeAccessEnabled: z.boolean(),
emailLoginNotificationEnabled: z.boolean()
});
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
@@ -47,26 +49,11 @@
async function onSubmit() {
const data = form.validate();
if (!data) return false;
await callback({
...data,
emailEnabled: true
});
await callback(data);
toast.success('Email configuration updated successfully');
return true;
}
async function onDisable() {
emailEnabled = false;
await callback({ emailEnabled });
toast.success('Email disabled successfully');
}
async function onEnable() {
if (await onSubmit()) {
emailEnabled = true;
}
}
async function onTestEmail() {
isSendingTestEmail = true;
await appConfigService
@@ -80,7 +67,8 @@
</script>
<form onsubmit={onSubmit}>
<div class="mt-5 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<h4 class="text-lg font-semibold">SMTP Configuration</h4>
<div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
@@ -99,15 +87,26 @@
bind:checked={$inputs.smtpSkipCertVerify.value}
/>
</div>
<h4 class="mt-10 text-lg font-semibold">Enabled Emails</h4>
<div class="mt-4 flex flex-col gap-3">
<CheckboxWithLabel
id="email-login-notification"
label="Email Login Notification"
description="Send an email to the user when they log in from a new device."
bind:checked={$inputs.emailLoginNotificationEnabled.value}
/>
<CheckboxWithLabel
id="email-one-time-access"
label="Email One Time Access"
description="Allows users to sign in with a link sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry."
bind:checked={$inputs.emailOneTimeAccessEnabled.value}
/>
</div>
<div class="mt-8 flex flex-wrap justify-end gap-3">
{#if emailEnabled}
<Button variant="secondary" onclick={onDisable}>Disable</Button>
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
>Send Test Email</Button
>
<Button type="submit">Save</Button>
{:else}
<Button onclick={onEnable}>Enable</Button>
{/if}
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
>Send Test Email</Button
>
<Button type="submit">Save</Button>
</div>
</form>

View File

@@ -2,8 +2,6 @@
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
@@ -23,14 +21,14 @@
appName: appConfig.appName,
sessionDuration: appConfig.sessionDuration,
emailsVerified: appConfig.emailsVerified,
allowOwnAccountEdit: appConfig.allowOwnAccountEdit
allowOwnAccountEdit: appConfig.allowOwnAccountEdit,
};
const formSchema = z.object({
appName: z.string().min(2).max(30),
sessionDuration: z.number().min(1).max(43200),
emailsVerified: z.boolean(),
allowOwnAccountEdit: z.boolean()
allowOwnAccountEdit: z.boolean(),
});
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
@@ -63,7 +61,7 @@
label="Emails Verified"
description="Whether the user's email should be marked as verified for the OIDC clients."
bind:checked={$inputs.emailsVerified.value}
/>
/>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>

View File

@@ -88,7 +88,6 @@
label="Public Client"
description="Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app."
onCheckedChange={(v) => {
console.log(v)
if (v == true) form.setValue('pkceEnabled', true);
}}
bind:checked={$inputs.isPublic.value}

View File

@@ -29,11 +29,12 @@ test('Update email configuration', async ({ page }) => {
await page.getByLabel('SMTP User').fill('test@gmail.com');
await page.getByLabel('SMTP Password').fill('password');
await page.getByLabel('SMTP From').fill('test@gmail.com');
await page.getByRole('button', { name: 'Enable' }).nth(0).click();
await page.getByRole('status').click();
await page.getByLabel('Email Login Notification').click();
await page.getByLabel('Email One Time Access').click();
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await expect(page.getByRole('status')).toHaveText('Email configuration updated successfully');
await expect(page.getByRole('button', { name: 'Disable' })).toBeVisible();
await page.reload();
@@ -42,10 +43,8 @@ test('Update email configuration', async ({ page }) => {
await expect(page.getByLabel('SMTP User')).toHaveValue('test@gmail.com');
await expect(page.getByLabel('SMTP Password')).toHaveValue('password');
await expect(page.getByLabel('SMTP From')).toHaveValue('test@gmail.com');
await page.getByRole('button', { name: 'Disable' }).click();
await expect(page.getByRole('status')).toHaveText('Email disabled successfully');
await expect(page.getByLabel('Email Login Notification')).toBeChecked();
await expect(page.getByLabel('Email One Time Access')).toBeChecked();
});
test('Update application images', async ({ page }) => {
@@ -55,7 +54,7 @@ test('Update application images', async ({ page }) => {
await page.getByLabel('Light Mode Logo').setInputFiles('tests/assets/pingvin-share-logo.png');
await page.getByLabel('Dark Mode Logo').setInputFiles('tests/assets/nextcloud-logo.png');
await page.getByLabel('Background Image').setInputFiles('tests/assets/clouds.jpg');
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await page.getByRole('button', { name: 'Save' }).nth(2).click();
await expect(page.getByRole('status')).toHaveText('Images updated successfully');

View File

@@ -17,5 +17,5 @@ test('Sign in with expired one time access token fails', async ({ page }) => {
await page.goto(`/login/${token.token}`);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('status')).toHaveText('Token is invalid or expired');
await expect(page.getByRole('paragraph')).toHaveText('Token is invalid or expired. Please try again.');
});