Files
OwnCord/Server/api/auth_handler.go
T
jevb 25449eb204 feat: redesign login UI, add save-password, fix permissions, add audit logging, member_join broadcast
- Redesign ConnectPage with modern dark theme, profile cards with delete buttons, login/register toggle
- Add DPAPI-encrypted password saving with "Remember my password" checkbox
- Fix permission bit constants to match SCHEMA.md (Member role 0x663)
- Add migration 004 to fix existing Member role permissions
- Add comprehensive audit logging across all server packages (auth, admin, ws, setup)
- Add member_join WebSocket broadcast so new users appear in members list in real-time
- Add host URL normalization (strip scheme prefix) for reverse proxy compatibility
- Add REST API client, ChatService orchestrator, WebSocket service with reconnection
- Add model types (WsEnvelope payloads, API responses), converters, tests
2026-03-15 00:31:39 +01:00

325 lines
9.1 KiB
Go

package api
import (
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/microcosm-cc/bluemonday"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
)
// sanitizer strips all HTML from user-supplied strings before storage.
var sanitizer = bluemonday.StrictPolicy()
// genericAuthError is returned for all login/register failures to avoid
// revealing whether a username exists.
var genericAuthError = errorResponse{
Error: "INVALID_CREDENTIALS",
Message: "invalid invite or credentials",
}
// registerRequest is the JSON body for POST /api/v1/auth/register.
type registerRequest struct {
Username string `json:"username"`
Password string `json:"password"`
InviteCode string `json:"invite_code"`
}
// loginRequest is the JSON body for POST /api/v1/auth/login.
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// userResponse is the user shape included in auth responses.
type userResponse struct {
ID int64 `json:"id"`
Username string `json:"username"`
Avatar string `json:"avatar,omitempty"`
Status string `json:"status"`
RoleID int64 `json:"role_id"`
CreatedAt string `json:"created_at"`
}
// authSuccessResponse is returned on successful login/register.
type authSuccessResponse struct {
Token string `json:"token"`
User userResponse `json:"user"`
}
// MountAuthRoutes registers all auth endpoints on the given router.
// Rate limiters are applied per-endpoint as specified.
func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter) {
registerLimiter := limiter
loginLimiter := limiter
r.Route("/api/v1/auth", func(r chi.Router) {
r.With(RateLimitMiddleware(registerLimiter, 3, time.Minute)).
Post("/register", handleRegister(database))
r.With(RateLimitMiddleware(loginLimiter, 5, time.Minute)).
Post("/login", handleLogin(database, limiter))
r.With(AuthMiddleware(database)).
Post("/logout", handleLogout(database))
r.With(AuthMiddleware(database)).
Get("/me", handleMe())
})
}
// handleRegister processes POST /api/v1/auth/register.
func handleRegister(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT",
Message: "malformed request body",
})
return
}
req.Username = strings.TrimSpace(sanitizer.Sanitize(req.Username))
req.InviteCode = strings.TrimSpace(req.InviteCode)
if req.Username == "" || req.Password == "" || req.InviteCode == "" {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT",
Message: "username, password, and invite_code are required",
})
return
}
// Validate password strength before anything else.
if err := auth.ValidatePasswordStrength(req.Password); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT",
Message: err.Error(),
})
return
}
// Validate invite.
inv, err := database.GetInvite(req.InviteCode)
if err != nil || inv == nil || inv.Revoked {
writeJSON(w, http.StatusBadRequest, genericAuthError)
return
}
if err := database.UseInvite(req.InviteCode); err != nil {
writeJSON(w, http.StatusBadRequest, genericAuthError)
return
}
// Hash password.
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "SERVER_ERROR",
Message: "failed to process registration",
})
return
}
// Create user with default Member role (4).
uid, err := database.CreateUser(req.Username, hash, 4)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT",
Message: "registration failed — check your details",
})
return
}
ip := clientIP(r)
slog.Info("user registered", "username", req.Username, "user_id", uid, "ip", ip)
_ = database.LogAudit(uid, "user_register", "user", uid,
"new account created via invite")
// Issue session.
token, err := auth.GenerateToken()
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "SERVER_ERROR",
Message: "failed to create session",
})
return
}
device := r.Header.Get("User-Agent")
if _, err := database.CreateSession(uid, auth.HashToken(token), device, ip); err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "SERVER_ERROR",
Message: "failed to create session",
})
return
}
user, _ := database.GetUserByID(uid)
writeJSON(w, http.StatusCreated, authSuccessResponse{
Token: token,
User: toUserResponse(user),
})
}
}
// handleLogin processes POST /api/v1/auth/login.
func handleLogin(database *db.DB, limiter *auth.RateLimiter) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT",
Message: "malformed request body",
})
return
}
req.Username = strings.TrimSpace(req.Username)
req.Password = strings.TrimSpace(req.Password)
if req.Username == "" || req.Password == "" {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT",
Message: "username and password are required",
})
return
}
ip := clientIP(r)
// Check lockout first.
lockKey := "login_lock:" + ip
if limiter.IsLockedOut(lockKey) {
writeJSON(w, http.StatusTooManyRequests, errorResponse{
Error: "RATE_LIMITED",
Message: "account temporarily locked due to too many failed attempts",
})
return
}
// Constant-time lookup: always attempt bcrypt compare even when user
// does not exist to prevent timing-based username enumeration.
user, err := database.GetUserByUsername(req.Username)
failKey := "login_fail:" + ip
if err != nil || user == nil || !auth.CheckPassword(user.PasswordHash, req.Password) {
// Track failures; lockout after 10.
if !limiter.Allow(failKey, 10, 15*time.Minute) {
limiter.Lockout(lockKey, 15*time.Minute)
}
slog.Info("login failed", "ip", ip, "username_len", len(req.Username))
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "invalid credentials",
})
return
}
// Reset failure counter on success.
limiter.Reset(failKey)
if user.Banned {
slog.Warn("banned user login attempt", "username", user.Username, "user_id", user.ID, "ip", ip)
_ = database.LogAudit(user.ID, "login_blocked_banned", "user", user.ID,
"banned user attempted login from "+ip)
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "FORBIDDEN",
Message: "your account has been suspended",
})
return
}
// Issue session.
token, err := auth.GenerateToken()
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "SERVER_ERROR",
Message: "failed to create session",
})
return
}
device := r.Header.Get("User-Agent")
if _, err := database.CreateSession(user.ID, auth.HashToken(token), device, ip); err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "SERVER_ERROR",
Message: "failed to create session",
})
return
}
_ = database.UpdateUserStatus(user.ID, "online")
slog.Info("user logged in", "username", user.Username, "user_id", user.ID, "ip", ip)
_ = database.LogAudit(user.ID, "user_login", "user", user.ID,
"logged in from "+ip)
writeJSON(w, http.StatusOK, authSuccessResponse{
Token: token,
User: toUserResponse(user),
})
}
}
// handleLogout processes POST /api/v1/auth/logout.
func handleLogout(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, ok := r.Context().Value(SessionKey).(*db.Session)
if !ok || sess == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "not authenticated",
})
return
}
if err := database.DeleteSession(sess.TokenHash); err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "SERVER_ERROR",
Message: "failed to logout",
})
return
}
slog.Info("user logged out", "user_id", sess.UserID)
_ = database.LogAudit(sess.UserID, "user_logout", "user", sess.UserID, "")
w.WriteHeader(http.StatusNoContent)
}
}
// handleMe processes GET /api/v1/auth/me.
func handleMe() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserKey).(*db.User)
if !ok || user == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "not authenticated",
})
return
}
writeJSON(w, http.StatusOK, toUserResponse(user))
}
}
// toUserResponse converts a db.User to the API response shape.
func toUserResponse(u *db.User) userResponse {
avatar := ""
if u.Avatar != nil {
avatar = *u.Avatar
}
return userResponse{
ID: u.ID,
Username: u.Username,
Avatar: avatar,
Status: u.Status,
RoleID: u.RoleID,
CreatedAt: u.CreatedAt,
}
}