746 lines
24 KiB
Go
746 lines
24 KiB
Go
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
|
|
|
|
authCookieName = "notatest.refresh_token"
|
|
viewCookieName = "notatest.expires_at"
|
|
authCookiePath = "/api/auth/cookie"
|
|
)
|
|
|
|
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) (data.User, 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
|
|
}
|
|
|
|
rs.setAuthCookies(w, tokenPair, false)
|
|
|
|
// 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. The new access token and the updated user object's DTO will be returned.
|
|
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
|
|
}
|
|
|
|
nUSer, err := rs.Users.UpdatePassword(r.Context(), data.UpdatePasswordParams{
|
|
ID: user.ID,
|
|
PasswordHash: string(hashedPassword),
|
|
})
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to update password")
|
|
return
|
|
}
|
|
|
|
// Revoke all old tokens before generating a new one for this session
|
|
if err := rs.Users.RevokeAllUserRefreshTokens(r.Context(), user.ID); err != nil {
|
|
log.Error().Msgf("Failed to revoke refresh tokens: %s", err)
|
|
}
|
|
|
|
// Generate a new pair (access & refresh tokens)
|
|
tokenPair, err := rs.GenerateTokenPair(r.Context(), user.ID, user.IsAdmin)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to generate tokens")
|
|
return
|
|
}
|
|
|
|
rs.setAuthCookies(w, tokenPair, false)
|
|
|
|
response := map[string]any{
|
|
"access_token": tokenPair.AccessToken,
|
|
"user": userResponse{
|
|
ID: nUSer.ID,
|
|
Username: nUSer.Username,
|
|
IsAdmin: nUSer.IsAdmin,
|
|
CreatedAt: nUSer.CreatedAt,
|
|
UpdatedAt: nUSer.UpdatedAt,
|
|
},
|
|
}
|
|
|
|
// Return the new access token and the updated user object
|
|
respondJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// 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
|
|
rs.setAuthCookies(w, nil, true)
|
|
|
|
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
|
|
}
|
|
|
|
rs.setAuthCookies(w, tokenPair, false)
|
|
|
|
// 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
|
|
rs.setAuthCookies(w, nil, true)
|
|
|
|
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
|
|
}
|
|
|
|
func (rs authResource) setAuthCookies(w http.ResponseWriter, tokenPair *tokenPair, clearCookies bool) {
|
|
expirationTime := time.Now().Add(refreshTokenDuration)
|
|
expirationUnix := strconv.FormatInt(expirationTime.Unix(), 10)
|
|
|
|
log.Debug().Msgf("Setting authentication cookies (clearCookies: %t)", clearCookies)
|
|
|
|
var maxAge int
|
|
var value string
|
|
if clearCookies {
|
|
maxAge = 0 // Expires immediately
|
|
value = ""
|
|
} else {
|
|
maxAge = int(refreshTokenDuration.Seconds())
|
|
value = tokenPair.RefreshToken
|
|
}
|
|
|
|
// The actual auth cookie is httpOnly, i.e. not viewable by the client
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: authCookieName,
|
|
Value: value,
|
|
Domain: rs.Config.Domain,
|
|
Path: authCookiePath,
|
|
MaxAge: maxAge,
|
|
HttpOnly: true,
|
|
Secure: rs.Config.IsProd,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
|
|
// The information cookie can be used by the client to check how long it'll take until the
|
|
// actual auth cookie expires (notably `HttpOnly: false` is a must)
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: viewCookieName,
|
|
Value: expirationUnix,
|
|
Domain: rs.Config.Domain,
|
|
Path: authCookiePath,
|
|
MaxAge: maxAge,
|
|
HttpOnly: false,
|
|
Secure: rs.Config.IsProd,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|