1
0
forked from wrenn/wrenn
Files
wrenn-releases/internal/api/handlers_auth.go
pptx704 8e5d426638 Add team management endpoints
- Three-role model (owner/admin/member) with owner protection invariants
- Team CRUD: create, rename (admin+), soft-delete with VM cleanup (owner only)
- Member management: add by email, remove, role updates (admin+), leave
- Switch-team endpoint re-issues JWT after DB membership verification
- User email prefix search for add-member UI autocomplete
- JWT carries role as a hint; all authorization decisions verified from DB
- Team slug: immutable 12-char hex (e.g. a1b2c3-d1e2f3), reserved on soft-delete
- Migration adds slug + deleted_at to teams; backfills existing rows
2026-03-24 13:29:54 +06:00

263 lines
7.2 KiB
Go

package api
import (
"errors"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
)
type switchTeamRequest struct {
TeamID string `json:"team_id"`
}
type authHandler struct {
db *db.Queries
pool *pgxpool.Pool
jwtSecret []byte
}
func newAuthHandler(db *db.Queries, pool *pgxpool.Pool, jwtSecret []byte) *authHandler {
return &authHandler{db: db, pool: pool, jwtSecret: jwtSecret}
}
type signupRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type authResponse struct {
Token string `json:"token"`
UserID string `json:"user_id"`
TeamID string `json:"team_id"`
Email string `json:"email"`
}
// Signup handles POST /v1/auth/signup.
func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
var req signupRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
return
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if !strings.Contains(req.Email, "@") || len(req.Email) < 3 {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid email address")
return
}
if len(req.Password) < 8 {
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
return
}
ctx := r.Context()
passwordHash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
return
}
// Use a transaction to atomically create user + team + membership.
tx, err := h.pool.Begin(ctx)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to begin transaction")
return
}
defer tx.Rollback(ctx) //nolint:errcheck
qtx := h.db.WithTx(tx)
userID := id.NewUserID()
_, err = qtx.InsertUser(ctx, db.InsertUserParams{
ID: userID,
Email: req.Email,
PasswordHash: pgtype.Text{String: passwordHash, Valid: true},
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
writeError(w, http.StatusConflict, "email_taken", "an account with this email already exists")
return
}
writeError(w, http.StatusInternalServerError, "db_error", "failed to create user")
return
}
// Create default team.
teamID := id.NewTeamID()
if _, err := qtx.InsertTeam(ctx, db.InsertTeamParams{
ID: teamID,
Name: req.Email + "'s Team",
Slug: id.NewTeamSlug(),
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to create team")
return
}
if err := qtx.InsertTeamMember(ctx, db.InsertTeamMemberParams{
UserID: userID,
TeamID: teamID,
IsDefault: true,
Role: "owner",
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to add user to team")
return
}
if err := tx.Commit(ctx); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to commit signup")
return
}
token, err := auth.SignJWT(h.jwtSecret, userID, teamID, req.Email, "owner")
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
return
}
writeJSON(w, http.StatusCreated, authResponse{
Token: token,
UserID: userID,
TeamID: teamID,
Email: req.Email,
})
}
// Login handles POST /v1/auth/login.
func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
return
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "email and password are required")
return
}
ctx := r.Context()
user, err := h.db.GetUserByEmail(ctx, req.Email)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
return
}
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up user")
return
}
if !user.PasswordHash.Valid {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
return
}
if err := auth.CheckPassword(user.PasswordHash.String, req.Password); err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
return
}
team, err := h.db.GetDefaultTeamForUser(ctx, user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team")
return
}
membership, err := h.db.GetTeamMembership(ctx, db.GetTeamMembershipParams{UserID: user.ID, TeamID: team.ID})
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up membership")
return
}
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, membership.Role)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
return
}
writeJSON(w, http.StatusOK, authResponse{
Token: token,
UserID: user.ID,
TeamID: team.ID,
Email: user.Email,
})
}
// SwitchTeam handles POST /v1/auth/switch-team.
// Verifies from DB that the user is a member of the target team, then re-issues
// a JWT scoped to that team. The JWT's team_id is used as a pre-filter on all
// subsequent team-scoped requests; DB is the source of truth for actual permissions.
func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
var req switchTeamRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
return
}
if req.TeamID == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "team_id is required")
return
}
ctx := r.Context()
// Verify team exists and is not deleted.
team, err := h.db.GetTeam(ctx, req.TeamID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "not_found", "team not found")
return
}
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team")
return
}
if team.DeletedAt.Valid {
writeError(w, http.StatusNotFound, "not_found", "team not found")
return
}
// Verify membership from DB — JWT role is not trusted here.
membership, err := h.db.GetTeamMembership(ctx, db.GetTeamMembershipParams{
UserID: ac.UserID,
TeamID: req.TeamID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusForbidden, "forbidden", "not a member of this team")
return
}
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up membership")
return
}
token, err := auth.SignJWT(h.jwtSecret, ac.UserID, req.TeamID, ac.Email, membership.Role)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
return
}
writeJSON(w, http.StatusOK, authResponse{
Token: token,
UserID: ac.UserID,
TeamID: req.TeamID,
Email: ac.Email,
})
}