Files
Completed/internal/api/auth_routes.go
T
SansanandClaude Sonnet 5 384c4a7217 Restrict platform-list edits to admins
Anyone authenticated can still read the shared platform list, but only an
admin may add, rename or delete from it.

- users.is_admin boolean, defaulting to false. The very first account ever
  registered becomes an admin automatically (CreateUser's INSERT uses
  NOT EXISTS (SELECT 1 FROM users) for is_admin), so there's always at
  least one without manual setup; promoting anyone beyond that is a manual
  UPDATE for now (documented in the migration's comment on the column).
- GetSessionAndUser, GetUserByEmail and GetUserByID now also select
  is_admin, and it flows through to model.User (and so to
  /api/auth/{register,login,me}'s response) and to the auth middleware's
  authedUser.
- New requireAdminMiddleware + addAdminAuth, layered on top of the existing
  auth middleware. Wired onto create/update/delete-platform; list/get stay
  on plain addAuth, since reading the list only requires being logged in.

Verified end to end: the first registered user comes back is_admin:true,
the second false; the non-admin gets 403 on create/update/delete-platform
but 200 on list; the admin can do all of it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 11:39:21 +02:00

161 lines
4.6 KiB
Go

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",
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",
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",
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",
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
})
}