From 52cf197ea18e243426f5065250eb50bb525cd0a7 Mon Sep 17 00:00:00 2001 From: Francesco La Camera Date: Tue, 24 Feb 2026 23:55:32 +0100 Subject: [PATCH 1/5] feat(api)!: implement jwt --- docs/docs.go | 87 +++++++++------------- docs/swagger.json | 87 +++++++++------------- docs/swagger.yaml | 60 +++++++--------- go.mod | 1 + go.sum | 2 + internal/api/handler/auth.go | 62 ---------------- internal/api/handler/server.go | 107 ++++++++-------------------- internal/api/middleware/auth.go | 11 ++- internal/api/middleware/security.go | 21 ------ internal/api/routes.go | 7 +- internal/config/config.go | 66 ++++++++--------- 11 files changed, 166 insertions(+), 345 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 7094931..5470daa 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -514,25 +514,6 @@ const docTemplate = `{ } } }, - "/auth/csrf": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Auth" - ], - "summary": "Issue CSRF token", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/dto.CsrfResponse" - } - } - } - } - }, "/auth/forgot": { "post": { "consumes": [ @@ -547,15 +528,12 @@ const docTemplate = `{ "summary": "Request password reset", "parameters": [ { - "description": "{email: string}", + "description": "Forgot Password Info", "name": "request", "in": "body", "required": true, "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ForgotPasswordRequest" } } ], @@ -611,19 +589,6 @@ const docTemplate = `{ } } }, - "/auth/logout": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Log out", - "responses": { - "204": { - "description": "No Content" - } - } - } - }, "/auth/me": { "get": { "tags": [ @@ -845,15 +810,12 @@ const docTemplate = `{ "summary": "Reset password", "parameters": [ { - "description": "{token: string, password: string}", + "description": "Reset Password Info", "name": "request", "in": "body", "required": true, "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ResetPasswordRequest" } } ], @@ -2028,17 +1990,6 @@ const docTemplate = `{ } } }, - "dto.CsrfResponse": { - "type": "object", - "properties": { - "csrf": { - "type": "string" - }, - "signups_enabled": { - "type": "boolean" - } - } - }, "dto.DashboardTrendItem": { "type": "object", "properties": { @@ -2203,6 +2154,18 @@ const docTemplate = `{ } } }, + "dto.ForgotPasswordRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "example": "user@fsv-wh.de" + } + } + }, "dto.Link": { "type": "object", "properties": { @@ -2419,6 +2382,24 @@ const docTemplate = `{ } } }, + "dto.ResetPasswordRequest": { + "type": "object", + "required": [ + "password", + "token" + ], + "properties": { + "password": { + "type": "string", + "maxLength": 72, + "minLength": 8, + "example": "secret123" + }, + "token": { + "type": "string" + } + } + }, "dto.SearchResult": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 5eb202e..bd7d0d9 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -507,25 +507,6 @@ } } }, - "/auth/csrf": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Auth" - ], - "summary": "Issue CSRF token", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/dto.CsrfResponse" - } - } - } - } - }, "/auth/forgot": { "post": { "consumes": [ @@ -540,15 +521,12 @@ "summary": "Request password reset", "parameters": [ { - "description": "{email: string}", + "description": "Forgot Password Info", "name": "request", "in": "body", "required": true, "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ForgotPasswordRequest" } } ], @@ -604,19 +582,6 @@ } } }, - "/auth/logout": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Log out", - "responses": { - "204": { - "description": "No Content" - } - } - } - }, "/auth/me": { "get": { "tags": [ @@ -838,15 +803,12 @@ "summary": "Reset password", "parameters": [ { - "description": "{token: string, password: string}", + "description": "Reset Password Info", "name": "request", "in": "body", "required": true, "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ResetPasswordRequest" } } ], @@ -2021,17 +1983,6 @@ } } }, - "dto.CsrfResponse": { - "type": "object", - "properties": { - "csrf": { - "type": "string" - }, - "signups_enabled": { - "type": "boolean" - } - } - }, "dto.DashboardTrendItem": { "type": "object", "properties": { @@ -2196,6 +2147,18 @@ } } }, + "dto.ForgotPasswordRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "example": "user@fsv-wh.de" + } + } + }, "dto.Link": { "type": "object", "properties": { @@ -2412,6 +2375,24 @@ } } }, + "dto.ResetPasswordRequest": { + "type": "object", + "required": [ + "password", + "token" + ], + "properties": { + "password": { + "type": "string", + "maxLength": 72, + "minLength": 8, + "example": "secret123" + }, + "token": { + "type": "string" + } + } + }, "dto.SearchResult": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 2621b75..eda6acb 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -142,13 +142,6 @@ definitions: - body - title type: object - dto.CsrfResponse: - properties: - csrf: - type: string - signups_enabled: - type: boolean - type: object dto.DashboardTrendItem: properties: count: @@ -257,6 +250,14 @@ definitions: title: type: string type: object + dto.ForgotPasswordRequest: + properties: + email: + example: user@fsv-wh.de + type: string + required: + - email + type: object dto.Link: properties: label: @@ -402,6 +403,19 @@ definitions: - name - password type: object + dto.ResetPasswordRequest: + properties: + password: + example: secret123 + maxLength: 72 + minLength: 8 + type: string + token: + type: string + required: + - password + - token + type: object dto.SearchResult: properties: id: @@ -828,31 +842,17 @@ paths: summary: Get user avatar tags: - Auth - /auth/csrf: - get: - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/dto.CsrfResponse' - summary: Issue CSRF token - tags: - - Auth /auth/forgot: post: consumes: - application/json parameters: - - description: '{email: string}' + - description: Forgot Password Info in: body name: request required: true schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/dto.ForgotPasswordRequest' produces: - application/json responses: @@ -890,14 +890,6 @@ paths: summary: Log in tags: - Auth - /auth/logout: - post: - responses: - "204": - description: No Content - summary: Log out - tags: - - Auth /auth/me: get: responses: @@ -1037,14 +1029,12 @@ paths: consumes: - application/json parameters: - - description: '{token: string, password: string}' + - description: Reset Password Info in: body name: request required: true schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/dto.ResetPasswordRequest' produces: - application/json responses: diff --git a/go.mod b/go.mod index a91bc34..270e081 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/cel-go v0.26.1 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index a4c8ec5..5d60e06 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,8 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0 github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= diff --git a/internal/api/handler/auth.go b/internal/api/handler/auth.go index ce5966d..169f8a0 100644 --- a/internal/api/handler/auth.go +++ b/internal/api/handler/auth.go @@ -12,7 +12,6 @@ import ( "time" "github.com/fachschaftinformatik/web/internal/api/dto" - "github.com/fachschaftinformatik/web/internal/api/middleware" "github.com/fachschaftinformatik/web/internal/database" "github.com/fachschaftinformatik/web/internal/id" "github.com/fachschaftinformatik/web/internal/storage" @@ -243,32 +242,6 @@ func (s *Server) PostAuthLogin(w http.ResponseWriter, r *http.Request) { return } - s.SetCookie(w, SessionCookieName, "", -time.Hour, true) - - sessionID := id.New().String() - duration := SessionDuration - if payload.Remember { - duration = 30 * 24 * time.Hour - } - expiresAt := time.Now().Add(duration) - - ua := r.UserAgent() - ip := r.RemoteAddr - - _, err = s.DB.CreateSession(r.Context(), database.CreateSessionParams{ - ID: sessionID, - UserID: dbUser.ID, - ExpiresAt: expiresAt.UTC().Format(time.RFC3339), - UserAgent: &ua, - IpAddress: &ip, - }) - if err != nil { - s.Log.Error("Failed to create session", "err", err) - s.JsonError(w, "server_error", "Could not create session", http.StatusInternalServerError) - return - } - - s.SetCookie(w, SessionCookieName, sessionID, duration, true) s.RespondJSON(w, http.StatusOK, s.toUserResponse(dbUser)) } @@ -351,41 +324,6 @@ func (s *Server) PutAuthMe(w http.ResponseWriter, r *http.Request) { s.RespondJSON(w, http.StatusOK, s.toUserResponse(updatedUser)) } -// @Summary Log out -// @Tags Auth -// @Success 204 -// @Router /auth/logout [post] -func (s *Server) PostAuthLogout(w http.ResponseWriter, r *http.Request) { - session, ok := r.Context().Value(middleware.SessionKey).(database.Session) - if !ok { - s.JsonError(w, "unauthorized", "Not logged in", http.StatusUnauthorized) - return - } - - _ = s.DB.DeleteSession(r.Context(), session.ID) - - s.SetCookie(w, SessionCookieName, "", -time.Hour, true) - s.SetCookie(w, CsrfCookieName, "", -time.Hour, false) - - // Thoroughly flush browser state - w.Header().Set("Clear-Site-Data", "\"cookies\", \"cache\"") - w.WriteHeader(http.StatusNoContent) -} - -// @Summary Issue CSRF token -// @Tags Auth -// @Produce json -// @Success 200 {object} dto.CsrfResponse -// @Router /auth/csrf [get] -func (s *Server) GetAuthCsrf(w http.ResponseWriter, r *http.Request) { - csrfToken := uuid.NewString() - s.SetCookie(w, CsrfCookieName, csrfToken, CsrfDuration, false) - s.RespondJSON(w, http.StatusOK, dto.CsrfResponse{ - Csrf: csrfToken, - SignupsEnabled: s.Config.SignupsEnabled, - }) -} - // @Summary List users // @Tags Users // @Param limit query int false "Limit" diff --git a/internal/api/handler/server.go b/internal/api/handler/server.go index 2530e82..e17b882 100644 --- a/internal/api/handler/server.go +++ b/internal/api/handler/server.go @@ -3,9 +3,9 @@ package handler import ( "encoding/json" "errors" + "fmt" "net/http" "strings" - "time" "github.com/fachschaftinformatik/web/internal/api/dto" "github.com/fachschaftinformatik/web/internal/api/middleware" @@ -17,15 +17,12 @@ import ( "github.com/fachschaftinformatik/web/internal/storage" "github.com/go-chi/httplog/v2" "github.com/go-playground/validator/v10" + "github.com/golang-jwt/jwt/v5" "github.com/microcosm-cc/bluemonday" ) const ( - SessionCookieName = "__Host-session" - CsrfCookieName = "__Host-csrf" - SessionDuration = 24 * time.Hour - CsrfDuration = 24 * time.Hour - maxUploadSize = 256 << 20 // 256 MB + maxUploadSize = 256 << 20 // 256 MB ) type Server struct { @@ -129,92 +126,50 @@ func (s *Server) ToPublicUserResponse(user database.User) dto.PublicUserResponse } } -func (s *Server) SetCookie(w http.ResponseWriter, name string, value string, maxAge time.Duration, httpOnly bool) { - actualName := name - if !s.SecureCookies { - actualName = strings.TrimPrefix(name, "__Host-") +func (s *Server) Authenticate(w http.ResponseWriter, r *http.Request) (database.User, error) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return database.User{}, errors.New("authorization header missing") } - cookie := &http.Cookie{ - Name: actualName, - Value: value, - Path: "/", - MaxAge: int(maxAge.Seconds()), - HttpOnly: httpOnly, - Secure: s.SecureCookies, - SameSite: http.SameSiteLaxMode, - } - http.SetCookie(w, cookie) -} - -func (s *Server) Authenticate(w http.ResponseWriter, r *http.Request) (database.Session, database.User, error) { - name := SessionCookieName - if !s.SecureCookies { - name = strings.TrimPrefix(name, "__Host-") - } - - cookie, err := r.Cookie(name) - if err != nil { - return database.Session{}, database.User{}, errors.New("session cookie not found") + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + return database.User{}, errors.New("invalid authorization header format") } - sessionID := cookie.Value - ctx := r.Context() + tokenString := parts[1] + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(s.Config.InternalJWTSecret), nil + }) - session, err := s.DB.GetSession(ctx, sessionID) - if err != nil { - return database.Session{}, database.User{}, errors.New("invalid session") + if err != nil || !token.Valid { + return database.User{}, errors.New("invalid or expired token") } - if session.UserAgent != nil && *session.UserAgent != r.UserAgent() { - s.Log.Warn("Session user agent mismatch (potential hijack or browser update)", "sessionID", session.ID, "expectedUA", *session.UserAgent, "gotUA", r.UserAgent()) + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return database.User{}, errors.New("invalid token claims") } - expiresAt, err := time.Parse(time.RFC3339, session.ExpiresAt) - if err != nil || expiresAt.Before(time.Now()) { - return database.Session{}, database.User{}, errors.New("session expired or invalid") + sub, ok := claims["sub"].(string) + if !ok { + return database.User{}, errors.New("subject claim missing in token") } - lastSeen, err := time.Parse(time.RFC3339, session.LastSeen) - if err == nil && time.Since(lastSeen) > 5*time.Minute { - createdAt, err := time.Parse(time.RFC3339, session.CreatedAt) - if err != nil { - createdAt = time.Now() - } - - extension := SessionDuration - if expiresAt.Sub(createdAt) > 25*time.Hour { - extension = 30 * 24 * time.Hour - } - - newExpires := time.Now().UTC().Add(extension).Format(time.RFC3339) - s.DB.SlideSession(ctx, database.SlideSessionParams{ - ID: session.ID, - ExpiresAt: newExpires, - }) - } - - user, err := s.DB.GetUser(ctx, session.UserID) + uid, err := id.Parse(sub) if err != nil { - return database.Session{}, database.User{}, errors.New("user not found") + return database.User{}, errors.New("invalid user id in token") } - return session, user, nil -} - -func (s *Server) CheckCSRF(r *http.Request) error { - headerToken := r.Header.Get("X-CSRF-Token") - - name := CsrfCookieName - if !s.SecureCookies { - name = strings.TrimPrefix(name, "__Host-") + user, err := s.DB.GetUser(r.Context(), int64(uid)) + if err != nil { + return database.User{}, errors.New("user not found") } - cookie, err := r.Cookie(name) - if headerToken == "" || err != nil || headerToken != cookie.Value { - return errors.New("invalid CSRF token") - } - return nil + return user, nil } func (s *Server) BoolToInt(b bool) int64 { diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index 12973a0..c19c7de 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -8,19 +8,18 @@ import ( ) type AuthProvider interface { - Authenticate(w http.ResponseWriter, r *http.Request) (database.Session, database.User, error) + Authenticate(w http.ResponseWriter, r *http.Request) (database.User, error) JsonError(w http.ResponseWriter, err, msg string, status int) } const ( - UserKey = "user" - SessionKey = "session" + UserKey = "user" ) func RequireAuth(provider AuthProvider) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session, user, err := provider.Authenticate(w, r) + user, err := provider.Authenticate(w, r) if err != nil { provider.JsonError(w, "unauthorized", "Authentication required", http.StatusUnauthorized) return @@ -34,7 +33,6 @@ func RequireAuth(provider AuthProvider) func(http.Handler) http.Handler { return } ctx := context.WithValue(r.Context(), UserKey, user) - ctx = context.WithValue(ctx, SessionKey, session) next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -43,10 +41,9 @@ func RequireAuth(provider AuthProvider) func(http.Handler) http.Handler { func OptionalAuth(provider AuthProvider) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session, user, err := provider.Authenticate(w, r) + user, err := provider.Authenticate(w, r) if err == nil && user.Active == 1 && user.Verified == 1 { ctx := context.WithValue(r.Context(), UserKey, user) - ctx = context.WithValue(ctx, SessionKey, session) next.ServeHTTP(w, r.WithContext(ctx)) return } diff --git a/internal/api/middleware/security.go b/internal/api/middleware/security.go index cdb4dc7..15f3b9b 100644 --- a/internal/api/middleware/security.go +++ b/internal/api/middleware/security.go @@ -4,27 +4,6 @@ import ( "net/http" ) -type CSRFProvider interface { - CheckCSRF(r *http.Request) error - JsonError(w http.ResponseWriter, err, msg string, status int) -} - -func RequireCSRF(provider CSRFProvider) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" || r.Method == "TRACE" { - next.ServeHTTP(w, r) - return - } - if err := provider.CheckCSRF(r); err != nil { - provider.JsonError(w, "invalid_csrf", err.Error(), http.StatusForbidden) - return - } - next.ServeHTTP(w, r) - }) - } -} - func SecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if w.Header().Get("X-Frame-Options") == "" { diff --git a/internal/api/routes.go b/internal/api/routes.go index 4f0cd81..cc96ae4 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -24,7 +24,7 @@ func NewRouter(s *handler.Server, logger *httplog.Logger) http.Handler { r.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{s.Config.Domain}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, ExposedHeaders: []string{"Link", "X-Total-Count"}, AllowCredentials: true, MaxAge: 300, @@ -35,8 +35,6 @@ func NewRouter(s *handler.Server, logger *httplog.Logger) http.Handler { r.Get("/swagger/*", httpSwagger.WrapHandler) r.Route("/api/v1", func(r chi.Router) { - r.Get("/auth/csrf", s.GetAuthCsrf) - r.Group(func(r chi.Router) { r.Use(httprate.LimitByIP(20, 1*time.Minute)) r.Post("/auth/login", s.PostAuthLogin) @@ -63,7 +61,6 @@ func NewRouter(s *handler.Server, logger *httplog.Logger) http.Handler { r.Get("/media/{mediaId}/preview", s.GetMediaPreview) r.Group(func(r chi.Router) { - r.Use(middleware.RequireCSRF(s)) r.Use(middleware.OptionalAuth(s)) r.Get("/discussions", s.GetDiscussions) r.Get("/discussions/{postId}", s.GetDiscussionsId) @@ -74,10 +71,8 @@ func NewRouter(s *handler.Server, logger *httplog.Logger) http.Handler { }) r.Group(func(r chi.Router) { - r.Use(middleware.RequireCSRF(s)) r.Use(middleware.RequireAuth(s)) - r.Post("/auth/logout", s.PostAuthLogout) r.Get("/auth/me", s.GetAuthMe) r.Put("/auth/me", s.PutAuthMe) r.Post("/auth/me/avatar", s.PostAuthAvatar) diff --git a/internal/config/config.go b/internal/config/config.go index 9928019..791e6ca 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,42 +3,44 @@ package config import "os" type Config struct { - HTTPPort string - SecureCookies bool - DatabaseUrl string - Domain string - SMTPHost string - SMTPPort string - SMTPUser string - SMTPPass string - SMTPFrom string - S3Endpoint string - S3Bucket string - S3AccessKey string - S3SecretKey string - S3UseSSL bool - SignupsVerify bool - SignupsEnabled bool + HTTPPort string + SecureCookies bool + DatabaseUrl string + Domain string + SMTPHost string + SMTPPort string + SMTPUser string + SMTPPass string + SMTPFrom string + S3Endpoint string + S3Bucket string + S3AccessKey string + S3SecretKey string + S3UseSSL bool + SignupsVerify bool + SignupsEnabled bool + InternalJWTSecret string } func New() *Config { return &Config{ - HTTPPort: getEnv("HTTP_PORT", "80"), - SecureCookies: getEnv("SECURE_COOKIES", "true") == "true", - DatabaseUrl: getEnv("DATABASE_URL", "file:/data/sqlite.db?_journal_mode=WAL&_foreign_keys=on&_recursive_triggers=off&_busy_timeout=5000"), - Domain: getEnv("DOMAIN", "http://localhost:3000"), - SMTPHost: getEnv("SMTP_HOST", ""), - SMTPPort: getEnv("SMTP_PORT", ""), - SMTPUser: getEnv("SMTP_USERNAME", ""), - SMTPPass: getEnv("SMTP_PASSWORD", ""), - SMTPFrom: getEnv("SMTP_FROM", ""), - S3Endpoint: getEnv("S3_ENDPOINT", ""), - S3Bucket: getEnv("S3_BUCKET", ""), - S3AccessKey: getEnv("S3_ACCESS_KEY", ""), - S3SecretKey: getEnv("S3_SECRET_KEY", ""), - S3UseSSL: getEnv("S3_USE_SSL", "false") == "true", - SignupsVerify: getEnv("SIGNUPS_VERIFY", "true") == "true", - SignupsEnabled: getEnv("SIGNUPS_ENABLED", "true") == "true", + HTTPPort: getEnv("HTTP_PORT", "80"), + SecureCookies: getEnv("SECURE_COOKIES", "true") == "true", + DatabaseUrl: getEnv("DATABASE_URL", "file:/data/sqlite.db?_journal_mode=WAL&_foreign_keys=on&_recursive_triggers=off&_busy_timeout=5000"), + Domain: getEnv("DOMAIN", "http://localhost:3000"), + SMTPHost: getEnv("SMTP_HOST", ""), + SMTPPort: getEnv("SMTP_PORT", ""), + SMTPUser: getEnv("SMTP_USERNAME", ""), + SMTPPass: getEnv("SMTP_PASSWORD", ""), + SMTPFrom: getEnv("SMTP_FROM", ""), + S3Endpoint: getEnv("S3_ENDPOINT", ""), + S3Bucket: getEnv("S3_BUCKET", ""), + S3AccessKey: getEnv("S3_ACCESS_KEY", ""), + S3SecretKey: getEnv("S3_SECRET_KEY", ""), + S3UseSSL: getEnv("S3_USE_SSL", "false") == "true", + SignupsVerify: getEnv("SIGNUPS_VERIFY", "true") == "true", + SignupsEnabled: getEnv("SIGNUPS_ENABLED", "true") == "true", + InternalJWTSecret: getEnv("INTERNAL_JWT_SECRET", "change-me-internal-secret"), } } From 42f4992204a9274765c5e2b9dede05a43a71bcb3 Mon Sep 17 00:00:00 2001 From: Francesco La Camera Date: Tue, 24 Feb 2026 23:56:40 +0100 Subject: [PATCH 2/5] feat(website)!: migrate to next-auth and jwt for sessions --- website/app/(auth)/AuthPage.tsx | 49 +++----- website/app/(auth)/forgot/page.tsx | 4 +- website/app/(auth)/reset/page.tsx | 4 +- website/app/admin/layout.tsx | 5 - website/app/api/auth/[...nextauth]/route.ts | 2 + website/app/archive/layout.tsx | 5 - website/app/archive/page.tsx | 3 - website/app/d/[postId]/edit/layout.tsx | 5 - "website/app/d/\\[postId\\]/edit/layout.tsx" | 3 + website/app/d/new/layout.tsx | 5 - website/app/guards.tsx | 45 ------- website/app/providers.tsx | 34 ++---- website/app/settings/layout.tsx | 5 - website/auth.ts | 68 +++++++++++ website/bun.lock | 16 +++ website/next.config.ts | 9 +- website/package.json | 2 + website/proxy.ts | 78 ++++++++---- website/src/lib/api/index.ts | 4 +- website/src/lib/api/sdk.gen.ts | 12 +- website/src/lib/api/types.gen.ts | 56 ++------- website/src/lib/api/zod.gen.ts | 35 ++---- website/src/lib/auth.tsx | 120 +++---------------- website/src/lib/csrf.ts | 61 ---------- website/tsconfig.json | 13 +- 25 files changed, 221 insertions(+), 422 deletions(-) delete mode 100644 website/app/admin/layout.tsx create mode 100644 website/app/api/auth/[...nextauth]/route.ts delete mode 100644 website/app/archive/layout.tsx delete mode 100644 website/app/d/[postId]/edit/layout.tsx create mode 100644 "website/app/d/\\[postId\\]/edit/layout.tsx" delete mode 100644 website/app/d/new/layout.tsx delete mode 100644 website/app/guards.tsx delete mode 100644 website/app/settings/layout.tsx create mode 100644 website/auth.ts delete mode 100644 website/src/lib/csrf.ts diff --git a/website/app/(auth)/AuthPage.tsx b/website/app/(auth)/AuthPage.tsx index b175343..ffaf103 100644 --- a/website/app/(auth)/AuthPage.tsx +++ b/website/app/(auth)/AuthPage.tsx @@ -41,12 +41,12 @@ import CheckCircleOutlineRounded from '@mui/icons-material/CheckCircleOutlineRou import HelpOutlineRounded from '@mui/icons-material/HelpOutlineRounded'; import { useThemeMode } from '@lib/theme'; -import { postAuthLogin, postAuthRegister, getPrograms } from '@lib/api'; +import { postAuthRegister, getPrograms } from '@lib/api'; import type { DtoProgramResponse as Program } from '@lib/api'; -import { useAuth, REMEMBERED_FLAG_KEY } from '@lib/auth'; +import { useAuth } from '@lib/auth'; import { translateError } from '@lib/errors'; import { zDtoLoginRequest, zDtoRegisterRequest } from '@lib/api/zod.gen'; -import { fetchCsrfToken } from '@lib/csrf'; +import { signIn } from 'next-auth/react'; const ALLOWED_DOMAINS = ['@studmail.w-hs.de', '@fsv-wh.de']; @@ -106,29 +106,12 @@ export default function AuthPage() { const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [programs, setPrograms] = useState([]); const [signupsEnabled, setSignupsEnabled] = useState(true); - const [rememberMe, setRememberMe] = useState(() => { - if (typeof window === 'undefined') return false; - return window.localStorage.getItem(REMEMBERED_FLAG_KEY) === 'true'; - }); + const [rememberMe, setRememberMe] = useState(false); - const { login } = useAuth(); const { mode, setPreference } = useThemeMode(); const theme = useTheme(); - useEffect(() => { - fetchCsrfToken().then(data => { - if (data) { - const enabled = data.signups_enabled ?? true; - setSignupsEnabled(enabled); - if (!enabled && tabValue === 1) { - setTabValue(0); - router.push('/login'); - } - } - }); - }, [router, tabValue]); - useEffect(() => { if (tabValue === 1 && programs.length === 0) { getPrograms() @@ -182,25 +165,21 @@ export default function AuthPage() { const { emailPrefix, emailDomain, password } = validationResult.data; try { - const { data: user, error: apiError } = await postAuthLogin({ - body: { - email: `${emailPrefix}${emailDomain}`, - password, - remember: rememberMe - } + const result = await signIn('credentials', { + email: `${emailPrefix}${emailDomain}`, + password, + redirect: false, }); - if (apiError) { - setErrors({ global: translateError(apiError) }); + if (result?.error) { + setErrors({ global: "Ungültige E-Mail oder Passwort." }); setLoading(false); return; } - if (user) { - login(user, rememberMe); - router.push(`/u/${user.id}`); - } + + router.push('/'); } catch (err) { - setErrors({ global: translateError(err) }); + setErrors({ global: "Anmeldung fehlgeschlagen." }); } finally { setLoading(false); } @@ -497,7 +476,7 @@ export default function AuthPage() {