package httpapi import ( "context" "crypto/rand" "database/sql" "encoding/hex" "net/http" "strings" "time" "balance-server/internal/auth" ) type credentials struct { Email string `json:"email"` Password string `json:"password"` } type refreshRequest struct { RefreshToken string `json:"refreshToken"` } type userResponse struct { ID string `json:"id"` Email string `json:"email"` } type sessionResponse struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` ExpiresAt time.Time `json:"expiresAt"` User userResponse `json:"user"` } func (api *API) register(w http.ResponseWriter, r *http.Request) { if !api.cfg.AllowRegistration { writeError(w, http.StatusForbidden, "registration_disabled", "Registration is disabled") return } var request credentials if !decodeJSON(w, r, &request) { return } email, err := normalizeEmail(request.Email) if err != nil { writeError(w, http.StatusBadRequest, "invalid_email", "Enter a valid email address") return } passwordHash, err := auth.HashPassword(request.Password) if err != nil { writeError(w, http.StatusBadRequest, "invalid_password", err.Error()) return } userID, err := identifier() if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Could not create account") return } _, err = api.db.ExecContext(r.Context(), `INSERT INTO users(id,email,password_hash,created_at) VALUES(?,?,?,?)`, userID, email, passwordHash, time.Now().UTC().Format(time.RFC3339Nano)) if err != nil { if containsUnique(err) { writeError(w, http.StatusConflict, "email_exists", "An account with this email already exists") return } api.logger.Error("create user", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Could not create account") return } response, err := api.createSession(r, userID, email) if err != nil { api.logger.Error("create session", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Could not create session") return } api.setSessionCookies(w, r, response) writeJSON(w, http.StatusCreated, response) } func (api *API) login(w http.ResponseWriter, r *http.Request) { var request credentials if !decodeJSON(w, r, &request) { return } email, err := normalizeEmail(request.Email) if err != nil { writeError(w, http.StatusUnauthorized, "invalid_credentials", "Email or password is incorrect") return } var userID, passwordHash string err = api.db.QueryRowContext(r.Context(), `SELECT id,password_hash FROM users WHERE email=?`, email).Scan(&userID, &passwordHash) if err != nil || !auth.VerifyPassword(passwordHash, request.Password) { writeError(w, http.StatusUnauthorized, "invalid_credentials", "Email or password is incorrect") return } response, err := api.createSession(r, userID, email) if err != nil { api.logger.Error("create session", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Could not create session") return } api.setSessionCookies(w, r, response) writeJSON(w, http.StatusOK, response) } func (api *API) refresh(w http.ResponseWriter, r *http.Request) { var request refreshRequest if !decodeJSON(w, r, &request) { return } request.RefreshToken = api.refreshToken(r, request.RefreshToken) if request.RefreshToken == "" { writeError(w, http.StatusBadRequest, "refresh_token_required", "Refresh token is required") return } tx, err := api.db.BeginTx(r.Context(), nil) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Could not refresh session") return } defer tx.Rollback() var userID, email, expiresText string err = tx.QueryRowContext(r.Context(), `SELECT u.id,u.email,t.expires_at FROM refresh_tokens t JOIN users u ON u.id=t.user_id WHERE t.token_hash=? AND t.revoked_at IS NULL`, auth.TokenHash(request.RefreshToken)).Scan(&userID, &email, &expiresText) if err != nil { writeError(w, http.StatusUnauthorized, "invalid_refresh_token", "Refresh token is invalid or expired") return } expires, err := time.Parse(time.RFC3339Nano, expiresText) if err != nil || !time.Now().UTC().Before(expires) { writeError(w, http.StatusUnauthorized, "invalid_refresh_token", "Refresh token is invalid or expired") return } _, err = tx.ExecContext(r.Context(), `UPDATE refresh_tokens SET revoked_at=? WHERE token_hash=?`, time.Now().UTC().Format(time.RFC3339Nano), auth.TokenHash(request.RefreshToken)) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Could not refresh session") return } response, err := api.createSessionWithExecutor(r, userID, email, tx) if err != nil || tx.Commit() != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Could not refresh session") return } api.setSessionCookies(w, r, response) writeJSON(w, http.StatusOK, response) } func (api *API) logout(w http.ResponseWriter, r *http.Request) { var request refreshRequest if !decodeJSON(w, r, &request) { return } request.RefreshToken = api.refreshToken(r, request.RefreshToken) if request.RefreshToken != "" { _, _ = api.db.ExecContext(r.Context(), `UPDATE refresh_tokens SET revoked_at=? WHERE token_hash=? AND revoked_at IS NULL`, time.Now().UTC().Format(time.RFC3339Nano), auth.TokenHash(request.RefreshToken)) } api.clearSessionCookies(w, r) w.WriteHeader(http.StatusNoContent) } func (api *API) me(w http.ResponseWriter, r *http.Request) { value := claims(r) writeJSON(w, http.StatusOK, userResponse{ID: value.Subject, Email: value.Email}) } func (api *API) createSession(r *http.Request, userID, email string) (sessionResponse, error) { return api.createSessionWithExecutor(r, userID, email, api.db) } func (api *API) createSessionWithExecutor(r *http.Request, userID, email string, executor interface { ExecContext(context.Context, string, ...any) (sql.Result, error) }) (sessionResponse, error) { accessToken, expiresAt, err := api.tokens.AccessToken(userID, email) if err != nil { return sessionResponse{}, err } refreshToken, err := auth.RandomToken(48) if err != nil { return sessionResponse{}, err } refreshExpires := time.Now().UTC().Add(api.cfg.RefreshTTL) _, err = executor.ExecContext(r.Context(), `INSERT INTO refresh_tokens(token_hash,user_id,expires_at,created_at) VALUES(?,?,?,?)`, auth.TokenHash(refreshToken), userID, refreshExpires.Format(time.RFC3339Nano), time.Now().UTC().Format(time.RFC3339Nano)) if err != nil { return sessionResponse{}, err } return sessionResponse{AccessToken: accessToken, RefreshToken: refreshToken, ExpiresAt: expiresAt, User: userResponse{ID: userID, Email: email}}, nil } func identifier() (string, error) { value := make([]byte, 16) if _, err := rand.Read(value); err != nil { return "", err } return hex.EncodeToString(value), nil } func containsUnique(err error) bool { return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") } const ( accessCookieName = "balance_access" refreshCookieName = "balance_refresh" ) func (api *API) setSessionCookies(w http.ResponseWriter, r *http.Request, session sessionResponse) { secure := requestIsSecure(r) http.SetCookie(w, &http.Cookie{ Name: accessCookieName, Value: session.AccessToken, Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, Expires: session.ExpiresAt, MaxAge: maxAge(session.ExpiresAt), }) refreshExpires := time.Now().UTC().Add(api.cfg.RefreshTTL) http.SetCookie(w, &http.Cookie{ Name: refreshCookieName, Value: session.RefreshToken, Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, Expires: refreshExpires, MaxAge: maxAge(refreshExpires), }) } func (api *API) clearSessionCookies(w http.ResponseWriter, r *http.Request) { for _, name := range []string{accessCookieName, refreshCookieName} { http.SetCookie(w, &http.Cookie{ Name: name, Value: "", Path: "/", HttpOnly: true, Secure: requestIsSecure(r), SameSite: http.SameSiteStrictMode, MaxAge: -1, Expires: time.Unix(1, 0), }) } } func (api *API) refreshToken(r *http.Request, value string) string { if value = strings.TrimSpace(value); value != "" { return value } if cookie, err := r.Cookie(refreshCookieName); err == nil { return strings.TrimSpace(cookie.Value) } return "" } func requestIsSecure(r *http.Request) bool { return r.TLS != nil || strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") } func maxAge(expires time.Time) int { seconds := int(time.Until(expires).Seconds()) if seconds < 1 { return 1 } return seconds }