package api import ( "context" "net/http" "time" "github.com/danielgtaylor/huma/v2" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "completed/internal/auth" "completed/internal/db" "completed/internal/model" ) type authOutput struct { SetCookie string `header:"Set-Cookie"` Body model.AuthResponse } // sessionCookie builds the Set-Cookie value for a successful login. Secure // is left unset because the app is also meant to run over plain HTTP in // local/docker-compose use; put a TLS-terminating proxy in front for // anything reachable over the network and set Secure there instead. func sessionCookie(token string) string { c := http.Cookie{ Name: sessionCookieName, Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: auth.SessionDuration, } return c.String() } func clearedSessionCookie() string { c := http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1, } return c.String() } // newSession creates and stores a session for userID, returning the token // to hand back to the client (only its hash is ever persisted). func newSession(ctx context.Context, queries db.Querier, userID uuid.UUID) (string, error) { token, tokenHash, err := auth.NewSessionToken() if err != nil { return "", err } err = queries.CreateSession(ctx, db.CreateSessionParams{ TokenHash: tokenHash, UserID: userID, ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(auth.SessionDuration * time.Second), Valid: true}, }) return token, err } func registerAuthRoutes(api huma.API, queries db.Querier) { huma.Register(api, huma.Operation{ OperationID: "register", Tags: []string{"Auth"}, Method: http.MethodPost, Path: "/api/auth/register", Summary: "Register a new account", }, func(ctx context.Context, input *struct{ Body model.RegisterInput }) (*authOutput, error) { hash, err := auth.HashPassword(input.Body.Password) if err != nil { return nil, huma.Error500InternalServerError("could not process password") } u, err := queries.CreateUser(ctx, db.CreateUserParams{ Email: input.Body.Email, PasswordHash: hash, Name: input.Body.Name, Bio: pgtype.Text{}, }) if err != nil { return nil, dbError(err) } token, err := newSession(ctx, queries, u.ID) if err != nil { return nil, huma.Error500InternalServerError("could not create session") } return &authOutput{ SetCookie: sessionCookie(token), Body: model.AuthResponse{ Token: token, User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin}, }, }, nil }) huma.Register(api, huma.Operation{ OperationID: "login", Tags: []string{"Auth"}, Method: http.MethodPost, Path: "/api/auth/login", Summary: "Log in to an existing account", }, func(ctx context.Context, input *struct{ Body model.LoginInput }) (*authOutput, error) { u, err := queries.GetUserByEmail(ctx, input.Body.Email) if err != nil { // Same message whether the email is unknown or the password is // wrong, so a client can't use this endpoint to enumerate // registered emails. return nil, huma.Error401Unauthorized("invalid email or password") } ok, err := auth.VerifyPassword(input.Body.Password, u.PasswordHash) if err != nil || !ok { return nil, huma.Error401Unauthorized("invalid email or password") } token, err := newSession(ctx, queries, u.ID) if err != nil { return nil, huma.Error500InternalServerError("could not create session") } return &authOutput{ SetCookie: sessionCookie(token), Body: model.AuthResponse{ Token: token, User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin}, }, }, nil }) logoutOp := huma.Operation{ OperationID: "logout", Tags: []string{"Auth"}, Method: http.MethodPost, Path: "/api/auth/logout", Summary: "Log out of the current session", } addAuth(&logoutOp, api, queries) huma.Register(api, logoutOp, func(ctx context.Context, input *struct{}) (*struct { SetCookie string `header:"Set-Cookie"` }, error) { _ = queries.DeleteSession(ctx, auth.HashSessionToken(tokenFromContext(ctx))) return &struct { SetCookie string `header:"Set-Cookie"` }{SetCookie: clearedSessionCookie()}, nil }) meOp := huma.Operation{ OperationID: "get-me", Tags: []string{"Auth"}, Method: http.MethodGet, Path: "/api/auth/me", Summary: "Get the current user", } addAuth(&meOp, api, queries) huma.Register(api, meOp, func(ctx context.Context, input *struct{}) (*struct{ Body model.User }, error) { return &struct{ Body model.User }{Body: userFromContext(ctx).toModel()}, nil }) }