package service import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "strconv" "strings" "time" "git.umbrella.haus/ae/notatest/internal/data" "github.com/go-chi/chi/v5" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/gorilla/csrf" "github.com/jackc/pgx/v5/pgconn" "github.com/rs/zerolog/log" "golang.org/x/crypto/bcrypt" ) const ( accessTokenDuration = 15 * time.Minute refreshTokenDuration = 7 * 24 * time.Hour ) var ( ErrInvalidToken = errors.New("invalid token") ErrAuthHeaderInvalid = errors.New("token couldn't be parsed from Authorization header") ) // User object context key for incoming requests (handled by middlewares). Only `*userClaims` type // objects should be stored behind this key for consistency. type userCtxKey struct{} // DTO without sensitive data fields such as user's password hash. type userResponse struct { ID uuid.UUID `json:"id"` Username string `json:"username"` IsAdmin bool `json:"is_admin"` CreatedAt *time.Time `json:"created_at"` UpdatedAt *time.Time `json:"updated_at"` } // Custom JWT claims (should always be handled in the middleware layer). type userClaims struct { Admin bool `json:"admin"` TokenType string `json:"type"` // "access" or "refresh" jwt.RegisteredClaims // User's UUID should be stored in the subject claim } type tokenPair struct { AccessToken string RefreshToken string } // Mockable token related database operations interface. type TokenStore interface { CreateRefreshToken(ctx context.Context, arg data.CreateRefreshTokenParams) (data.RefreshToken, error) GetRefreshTokenByHash(ctx context.Context, tokenHash string) (data.RefreshToken, error) RevokeRefreshToken(ctx context.Context, tokenHash string) error RevokeAllUserRefreshTokens(ctx context.Context, id uuid.UUID) error } // Mockable user related database operations interface. type UserStore interface { CreateUser(ctx context.Context, arg data.CreateUserParams) (data.User, error) ListUsers(ctx context.Context, arg data.ListUsersParams) ([]data.User, error) GetUserByID(ctx context.Context, id uuid.UUID) (data.User, error) GetUserByUsername(ctx context.Context, username string) (data.User, error) UpdatePassword(ctx context.Context, arg data.UpdatePasswordParams) error DeleteUser(ctx context.Context, id uuid.UUID) error RevokeAllUserRefreshTokens(ctx context.Context, id uuid.UUID) error } // Chi HTTP router for authentication/authorization related actions (users/tokens). In theory // (especially in production) the `UserStore` and `TokenStore` will point to the same database // handler, but for code readability they should be kept in separate structs. type authResource struct { Config SvcConfig Users UserStore Tokens TokenStore } func (rs authResource) Routes() chi.Router { r := chi.NewRouter() // Public routes r.Post("/signup", rs.Create) // POST /auth/signup - registration r.Post("/login", rs.Login) // POST /auth/login - login // Protected routes (access token required) r.Group(func(r chi.Router) { r.Use(requireAccessToken(rs.Config.JWTSecret)) // JWT claims -> ctx. r.Get("/me", rs.Get) // GET /auth/me - current user data r.Post("/logout", rs.Logout) // POST /auth/logout - revoke all refresh cookies // Owner routes r.Route("/owner", func(r chi.Router) { r.Put("/", rs.UpdatePassword) // PUT /auth/owner - update user password r.Delete("/", rs.OwnerDelete) // DELETE /auth/owner - delete user (owner) }) // Administration routes (admin claim required) r.Route("/admin", func(r chi.Router) { r.Use(adminOnlyMiddleware) r.Get("/all", rs.List) // GET /auth/admin/all - list all users r.Route(fmt.Sprintf("/{%s}", targetUserUUIDCtxParameter), func(r chi.Router) { r.Use(uuidCtx(targetUserUUIDCtxParameter)) r.Delete("/", rs.AdminDelete) // DELETE /auth/admin/{id} - delete user (admin) }) }) }) // Protected routes (refresh token required) r.Route("/cookie", func(r chi.Router) { // The refresh token httpOnly cookie is restricted to `/api/auth/cookie`, which is why this // is the only endpoint where CSRF must be taken into account (HTTPS requirement disabled // for local development) if rs.Config.IsProd { r.Use(csrf.Protect([]byte(rs.Config.CSRFSecret))) } else { r.Use(csrf.Protect([]byte(rs.Config.CSRFSecret), csrf.Secure(false))) } r.Use(requireRefreshToken(rs.Config.JWTSecret)) r.Get("/csrf", rs.GetCSRFToken) // GET /auth/cookie/csrf - get a new CSRF token in response headers r.Post("/refresh", rs.RefreshAccessToken) // POST /auth/cookie/refresh - convert refresh token to new token pair }) return r } // Handler for new user creation. Will check the incoming JSON object's integrity, validate/normalize // the username, and validate the password (check whether it's compromised via the HIBP API and // calculate its entropy). func (rs authResource) Create(w http.ResponseWriter, r *http.Request) { type request struct { Username *string `json:"username"` Password *string `json:"password"` } var req request if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == nil || req.Password == nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } // Username normalization (to lowercase) normalizedUsername := normalizeUsername(*req.Username) if err := validateUsername(normalizedUsername); err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } // Password validation (length, HIBP API, and entropy) if err := validatePassword(*req.Password); err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to create user") return } user, err := rs.Users.CreateUser(r.Context(), data.CreateUserParams{ Username: normalizedUsername, PasswordHash: string(hashedPassword), }) if err != nil { if isDuplicateEntry(err) { respondError(w, http.StatusConflict, "Username is already in use") } else { respondError(w, http.StatusInternalServerError, "Failed to create user") } return } respondJSON(w, http.StatusCreated, map[string]string{ "id": user.ID.String(), "username": user.Username, }) } // Handler for logging in to an existing user account using a username-password credentials pair. // Will check the incoming JSON object's integrity, use a normalized version of the username for a // database lookup, and compare the given password's hash against the one stored in the database. // By default only returns a fresh access tokens (and a refresh token as a httpOnly cookie), but // if the `includeUser` parameter is set to `true` the user DTO will also be included. func (rs authResource) Login(w http.ResponseWriter, r *http.Request) { type request struct { Username *string `json:"username"` Password *string `json:"password"` } var req request if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == nil || req.Password == nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } user, err := rs.Users.GetUserByUsername(r.Context(), normalizeUsername(*req.Username)) if err != nil { respondError(w, http.StatusUnauthorized, "Invalid credentials") return } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(*req.Password)); err != nil { respondError(w, http.StatusUnauthorized, "Invalid credentials") return } // Generate a new access/refresh token pair and store a SHA256 hash of the refresh token into // the database for further token rotations. tokenPair, err := rs.GenerateTokenPair(r.Context(), user.ID, user.IsAdmin) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to generate tokens") return } // Set refresh token into a httpOnly cookie http.SetCookie(w, &http.Cookie{ Name: "notatest.refresh_token", Value: tokenPair.RefreshToken, Path: "/api/auth/cookie", MaxAge: int(refreshTokenDuration.Seconds()), HttpOnly: true, Secure: rs.Config.IsProd, SameSite: http.SameSiteStrictMode, }) // Build response response := map[string]any{ "access_token": tokenPair.AccessToken, } // Include user data DTO into the response if the `includeUser` parameter was set to `true` if includeUser, _ := strconv.ParseBool(r.URL.Query().Get("includeUser")); includeUser { response["user"] = userResponse{ ID: user.ID, Username: user.Username, IsAdmin: user.IsAdmin, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, } } respondJSON(w, http.StatusOK, response) } // Handler for getting full data of the current user as a DTO (database lookup) based on the JWT // claims set into the request's context by a middleware. func (rs authResource) Get(w http.ResponseWriter, r *http.Request) { user := rs.userFromCtxClaims(w, r) if user == nil { return } respondJSON(w, http.StatusOK, userResponse{ ID: user.ID, Username: user.Username, IsAdmin: user.IsAdmin, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, }) } // Handler for updating the current user's password. Performs the same password strength checks as // the registration handler (`rs.Create`) and revokes any existing refresh tokens the user has // stored in the database. func (rs authResource) UpdatePassword(w http.ResponseWriter, r *http.Request) { type request struct { OldPassword string `json:"old_password"` NewPassword string `json:"new_password"` } var req request if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } user := rs.userFromCtxClaims(w, r) if user == nil { return } // Verify the old password before proceeding with the update if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.OldPassword)); err != nil { respondError(w, http.StatusUnauthorized, "Invalid credentials") return } if err := validatePassword(req.NewPassword); err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to update password") return } if err := rs.Users.UpdatePassword(r.Context(), data.UpdatePasswordParams{ ID: user.ID, PasswordHash: string(hashedPassword), }); err != nil { respondError(w, http.StatusInternalServerError, "Failed to update password") return } if err := rs.Users.RevokeAllUserRefreshTokens(r.Context(), user.ID); err != nil { log.Error().Msgf("Failed to revoke refresh tokens: %s", err) } w.WriteHeader(http.StatusNoContent) } // Handler for hard deleting the current user. Requires the user's password as JSON input as a precaution. func (rs authResource) OwnerDelete(w http.ResponseWriter, r *http.Request) { type request struct { Password string `json:"password"` } var req request if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } user := rs.userFromCtxClaims(w, r) if user == nil { return } // Verify the old password before allowing the deletion if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { respondError(w, http.StatusUnauthorized, "Invalid credentials") return } err := rs.Users.DeleteUser(r.Context(), user.ID) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to delete user") return } // Clear the refresh token cookie http.SetCookie(w, &http.Cookie{ Name: "notatest.refresh_token", Value: "", Path: "/api/auth/cookie", MaxAge: 0, // Expires immediately HttpOnly: true, Secure: rs.Config.IsProd, SameSite: http.SameSiteStrictMode, }) if err := rs.Users.RevokeAllUserRefreshTokens(r.Context(), user.ID); err != nil { log.Error().Msgf("Failed to revoke refresh tokens: %s", err) } w.WriteHeader(http.StatusNoContent) } // Handler for listing all users stored in the database. Should only be allowed to be called by // administrator level users. func (rs authResource) List(w http.ResponseWriter, r *http.Request) { limit, offset := getPaginationParams(r) users, err := rs.Users.ListUsers(r.Context(), data.ListUsersParams{ Limit: limit, Offset: offset, }) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to retrieve users") return } // Output sanitization to DTO var output []userResponse for _, user := range users { output = append(output, userResponse{ ID: user.ID, Username: user.Username, IsAdmin: user.IsAdmin, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, }) } respondJSON(w, http.StatusOK, output) } // Handler for deleting another user account based on their ID. Will check the existence of the // user based on the given ID and additionally revoke all the stored refresh tokens on successful // deletion. Should only be allowed to be called by administrator level users. func (rs authResource) AdminDelete(w http.ResponseWriter, r *http.Request) { ctx := r.Context() targetID, ok := ctx.Value(uuidCtxKey{Name: targetUserUUIDCtxParameter}).(uuid.UUID) if !ok { respondError(w, http.StatusBadRequest, "Resource ID missing") return } if err := rs.Users.DeleteUser(r.Context(), targetID); err != nil { respondError(w, http.StatusInternalServerError, "Failed to delete user") return } if err := rs.Users.RevokeAllUserRefreshTokens(r.Context(), targetID); err != nil { log.Error().Msgf("Failed to revoke refresh tokens: %s", err) } w.WriteHeader(http.StatusNoContent) } // Generate a new pair of access and refresh tokens (JWTs) with the user's UUID as the identifying // `Subject` claim and custom claims for the user's administrator status (boolean) and token type // ("refresh"/"access"). Stores a SHA256 hash of the refresh token into the database for further // token rotations. func (rs authResource) GenerateTokenPair(ctx context.Context, userID uuid.UUID, isAdmin bool) (*tokenPair, error) { tokenPair, err := generateTokenPair(userID.String(), isAdmin, rs.Config.JWTSecret) if err != nil { return nil, err } hash := sha256.Sum256([]byte(tokenPair.RefreshToken)) tokenHash := hex.EncodeToString(hash[:]) // Store the SHA256 hash of the refresh token with (almost) identical expiration timestamp expiresAt := time.Now().Add(refreshTokenDuration) _, err = rs.Tokens.CreateRefreshToken(ctx, data.CreateRefreshTokenParams{ UserID: userID, TokenHash: tokenHash, ExpiresAt: expiresAt, }) if err != nil { return nil, err } return tokenPair, nil } // Revoke the given token from the database by calculating its SHA256 hash. func (rs authResource) RevokeRefreshToken(ctx context.Context, token string) error { hash := sha256.Sum256([]byte(token)) tokenHash := hex.EncodeToString(hash[:]) return rs.Tokens.RevokeRefreshToken(ctx, tokenHash) } // Validate the given refresh token by performing a database lookup with its SHA256 hash. Returns // the refresh token database object on successful lookup. Fails if the token has been revoked // (soft database operation) or expired (i.e. the corresponding user has to log in again). func (rs authResource) ValidateRefreshToken(ctx context.Context, token string) (*data.RefreshToken, error) { hash := sha256.Sum256([]byte(token)) tokenHash := hex.EncodeToString(hash[:]) dbToken, err := rs.Tokens.GetRefreshTokenByHash(ctx, tokenHash) if err != nil { return nil, err } // Check for soft revocation and/or expiration if dbToken.Revoked || time.Now().After(dbToken.ExpiresAt) { return nil, ErrInvalidToken } return &dbToken, nil } // Handler for returning the CSRF token in the `X-CSRF-Token` header. Notably this request doesn't // need to contain a valid CSRF token as it uses "safe" (non-mutating) method GET, which means CSRF // won't be enforced. func (rs authResource) GetCSRFToken(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-CSRF-Token", csrf.Token(r)) w.WriteHeader(http.StatusNoContent) } // Handler for performing a token rotation, i.e. invalidating the given refresh token (each refresh // token is a single use utility) and exchanging it for a new pair of refresh and access tokens. func (rs authResource) RefreshAccessToken(w http.ResponseWriter, r *http.Request) { // Get claims from context claims, ok := r.Context().Value(userCtxKey{}).(*userClaims) if !ok || claims.TokenType != "refresh" { respondError(w, http.StatusUnauthorized, "Invalid token") return } // Attempt to get the token from httpOnly cookie refreshToken, err := getTokenFromCookie(r) if err != nil { respondError(w, http.StatusUnauthorized, "Unauthorized") return } // Validate the refresh token in the database if _, err := rs.ValidateRefreshToken(r.Context(), refreshToken); err != nil { respondError(w, http.StatusUnauthorized, "Invalid refresh token") return } // Revoke the given (single use) refresh token if err := rs.RevokeRefreshToken(r.Context(), refreshToken); err != nil { respondError(w, http.StatusInternalServerError, "Failed to revoke token") return } userID, err := uuid.Parse(claims.Subject) if err != nil { respondError(w, http.StatusBadRequest, "Invalid user ID") return } // Generate a new pair (access & refresh tokens) tokenPair, err := rs.GenerateTokenPair(r.Context(), userID, claims.Admin) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to generate tokens") return } // Set refresh token into a httpOnly cookie http.SetCookie(w, &http.Cookie{ Name: "notatest.refresh_token", Value: tokenPair.RefreshToken, Path: "/api/auth/cookie", MaxAge: int(refreshTokenDuration.Seconds()), HttpOnly: true, Secure: rs.Config.IsProd, SameSite: http.SameSiteStrictMode, }) // Return the access token in the response body (it should be stored in browser's memory client-side) respondJSON(w, http.StatusOK, map[string]string{ "access_token": tokenPair.AccessToken, }) } // Handler for performing a logout process for the current user, i.e. replacing the current // httpOnly `refresh_token` cookie with one that expires immediately. Theoretically the user // will still be able to authenticate until the access token (stored client-side) expires, // but that's up to the client to handle. func (rs authResource) Logout(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(userCtxKey{}).(*userClaims) if !ok { respondError(w, http.StatusUnauthorized, "Not authenticated") return } userID, err := uuid.Parse(claims.Subject) if err != nil { respondError(w, http.StatusBadRequest, "Invalid user ID") return } // Clear the refresh token cookie http.SetCookie(w, &http.Cookie{ Name: "notatest.refresh_token", Value: "", Path: "/api/auth/cookie", MaxAge: 0, // Expires immediately HttpOnly: true, Secure: rs.Config.IsProd, SameSite: http.SameSiteStrictMode, }) if err := rs.Tokens.RevokeAllUserRefreshTokens(r.Context(), userID); err != nil { respondError(w, http.StatusInternalServerError, "Failed to logout") return } w.WriteHeader(http.StatusNoContent) } // Parse JWT claims (`userClaims`) from the request's context, and perform a database lookup based // on `Subject` (after parsing it to `uuid.UUID`) to fetch the corresponding user's data. func (rs authResource) userFromCtxClaims(w http.ResponseWriter, r *http.Request) *data.User { claims, ok := r.Context().Value(userCtxKey{}).(*userClaims) if !ok { respondError(w, http.StatusUnauthorized, "Unauthorized") return nil } userID, err := uuid.Parse(claims.Subject) if err != nil { respondError(w, http.StatusBadRequest, "Invalid user ID") return nil } user, err := rs.Users.GetUserByID(r.Context(), userID) if err != nil { respondError(w, http.StatusNotFound, "User not found") return nil } return &user } // Helper function for generating the initial administrator level account if one doesn't already // exists in the database. func CreateAdminIfNotExists(ctx context.Context, q *data.Queries, username, password string) error { admins, err := q.ListAdmins(ctx) if err != nil { return err } if len(admins) > 0 { log.Debug().Msg("Admin accounts already exist, skipping creation") return nil } // Username normalization (to lowercase) normalizedUsername := normalizeUsername(username) if err := validateUsername(normalizedUsername); err != nil { return err } // Password validation (length, HIBP API, and entropy) if err := validatePassword(password); err != nil { return err } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } _, err = q.CreateAdmin(ctx, data.CreateAdminParams{ Username: normalizedUsername, PasswordHash: string(hashedPassword), }) if err != nil { return err } log.Info().Msgf("Initial admin user '%s' created successfully", username) return nil } // Parse the JWT token from the request's Authorization header. func getTokenFromHeader(r *http.Request) (string, error) { bearerToken := r.Header.Get("Authorization") bearerFields := strings.Fields(bearerToken) if len(bearerFields) == 2 && strings.ToLower(bearerFields[0]) == "bearer" { return bearerFields[1], nil } return "", ErrAuthHeaderInvalid } // Parse the JWT token from the request's cookies (httpOnly cookie). func getTokenFromCookie(r *http.Request) (string, error) { cookie, err := r.Cookie("notatest.refresh_token") if err != nil { return "", err } return cookie.Value, nil } // Helper function for generating a new JWT token pair with the given specifications. func generateTokenPair(userID string, isAdmin bool, jwtSecret string) (*tokenPair, error) { atClaims := userClaims{ Admin: isAdmin, TokenType: "access", RegisteredClaims: jwt.RegisteredClaims{ Subject: userID, ExpiresAt: jwt.NewNumericDate(time.Now().Add(accessTokenDuration)), IssuedAt: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now()), }, } accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, atClaims) t, err := accessToken.SignedString([]byte(jwtSecret)) if err != nil { return nil, err } rtClaims := userClaims{ Admin: isAdmin, TokenType: "refresh", RegisteredClaims: jwt.RegisteredClaims{ Subject: userID, ExpiresAt: jwt.NewNumericDate(time.Now().Add(refreshTokenDuration)), IssuedAt: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now()), }, } refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, rtClaims) rt, err := refreshToken.SignedString([]byte(jwtSecret)) if err != nil { return nil, err } return &tokenPair{AccessToken: t, RefreshToken: rt}, nil } // Check if the given error is a PostgreSQL error for `unique_violation` (error code 23505), i.e. // whether an entry with the given details already exists in the database table. func isDuplicateEntry(err error) bool { var pgErr *pgconn.PgError if errors.As(err, &pgErr) { return pgErr.Code == "23505" } return false }