402 lines
8.6 KiB
Go
402 lines
8.6 KiB
Go
// AngelaMos | 2026
|
|
// handler.go
|
|
|
|
package auth
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-playground/validator/v10"
|
|
|
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/core"
|
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware"
|
|
)
|
|
|
|
const (
|
|
refreshCookieName = "mts_rt"
|
|
refreshCookiePath = "/api/v1/auth"
|
|
refreshCookieMaxAge = 7 * 24 * time.Hour
|
|
)
|
|
|
|
type Handler struct {
|
|
service *Service
|
|
validator *validator.Validate
|
|
trustedProxyHops int
|
|
secureCookies bool
|
|
}
|
|
|
|
type HandlerConfig struct {
|
|
Service *Service
|
|
TrustedProxyHops int
|
|
SecureCookies bool
|
|
}
|
|
|
|
func NewHandler(service *Service, trustedProxyHops int) *Handler {
|
|
return NewHandlerWithConfig(HandlerConfig{
|
|
Service: service,
|
|
TrustedProxyHops: trustedProxyHops,
|
|
})
|
|
}
|
|
|
|
func NewHandlerWithConfig(cfg HandlerConfig) *Handler {
|
|
return &Handler{
|
|
service: cfg.Service,
|
|
validator: validator.New(validator.WithRequiredStructEnabled()),
|
|
trustedProxyHops: cfg.TrustedProxyHops,
|
|
secureCookies: cfg.SecureCookies,
|
|
}
|
|
}
|
|
|
|
func (h *Handler) setRefreshCookie(
|
|
w http.ResponseWriter,
|
|
token string,
|
|
expiresAt time.Time,
|
|
) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: refreshCookieName,
|
|
Value: token,
|
|
Path: refreshCookiePath,
|
|
HttpOnly: true,
|
|
Secure: h.secureCookies,
|
|
SameSite: http.SameSiteStrictMode,
|
|
Expires: expiresAt,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) clearRefreshCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: refreshCookieName,
|
|
Value: "",
|
|
Path: refreshCookiePath,
|
|
HttpOnly: true,
|
|
Secure: h.secureCookies,
|
|
SameSite: http.SameSiteStrictMode,
|
|
MaxAge: -1,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) refreshTokenFromRequest(r *http.Request, body string) string {
|
|
if body != "" {
|
|
return body
|
|
}
|
|
if c, err := r.Cookie(refreshCookieName); err == nil {
|
|
return c.Value
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *Handler) RegisterRoutes(
|
|
r chi.Router,
|
|
authenticator func(http.Handler) http.Handler,
|
|
) {
|
|
r.Route("/auth", func(r chi.Router) {
|
|
r.Post("/login", h.Login)
|
|
r.Post("/register", h.Register)
|
|
r.Post("/refresh", h.Refresh)
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(authenticator)
|
|
r.Get("/me", h.GetMe)
|
|
r.Post("/logout", h.Logout)
|
|
r.Post("/logout-all", h.LogoutAll)
|
|
r.Get("/sessions", h.GetSessions)
|
|
r.Delete("/sessions/{sessionID}", h.RevokeSession)
|
|
r.Post("/change-password", h.ChangePassword)
|
|
})
|
|
})
|
|
}
|
|
|
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|
var req LoginRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
core.BadRequest(w, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if err := h.validator.Struct(req); err != nil {
|
|
core.BadRequest(w, core.FormatValidationError(err))
|
|
return
|
|
}
|
|
|
|
userAgent := r.UserAgent()
|
|
ipAddress := h.extractIPAddress(r)
|
|
|
|
resp, err := h.service.Login(r.Context(), req, userAgent, ipAddress)
|
|
if err != nil {
|
|
if errors.Is(err, ErrInvalidCredentials) {
|
|
core.JSONError(
|
|
w,
|
|
core.UnauthorizedError("invalid email or password"),
|
|
)
|
|
return
|
|
}
|
|
core.InternalServerError(w, err)
|
|
return
|
|
}
|
|
|
|
h.setRefreshCookie(
|
|
w,
|
|
resp.Tokens.RefreshToken,
|
|
time.Now().Add(refreshCookieMaxAge),
|
|
)
|
|
core.OK(w, resp)
|
|
}
|
|
|
|
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
|
var req RegisterRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
core.BadRequest(w, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if err := h.validator.Struct(req); err != nil {
|
|
core.BadRequest(w, core.FormatValidationError(err))
|
|
return
|
|
}
|
|
|
|
userAgent := r.UserAgent()
|
|
ipAddress := h.extractIPAddress(r)
|
|
|
|
resp, err := h.service.Register(r.Context(), req, userAgent, ipAddress)
|
|
if err != nil {
|
|
if errors.Is(err, ErrEmailExists) {
|
|
core.JSONError(w, core.DuplicateError("email"))
|
|
return
|
|
}
|
|
core.InternalServerError(w, err)
|
|
return
|
|
}
|
|
|
|
h.setRefreshCookie(
|
|
w,
|
|
resp.Tokens.RefreshToken,
|
|
time.Now().Add(refreshCookieMaxAge),
|
|
)
|
|
core.Created(w, resp)
|
|
}
|
|
|
|
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
|
var req RefreshRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
// Body may be empty when frontend uses cookie-only flow.
|
|
req = RefreshRequest{}
|
|
}
|
|
|
|
refreshToken := h.refreshTokenFromRequest(r, req.RefreshToken)
|
|
if refreshToken == "" {
|
|
core.BadRequest(w, "refresh_token required (cookie or body)")
|
|
return
|
|
}
|
|
|
|
userAgent := r.UserAgent()
|
|
ipAddress := h.extractIPAddress(r)
|
|
|
|
resp, err := h.service.Refresh(
|
|
r.Context(),
|
|
refreshToken,
|
|
userAgent,
|
|
ipAddress,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, ErrTokenReuse) {
|
|
core.JSONError(w, core.NewAppError(
|
|
core.ErrTokenRevoked,
|
|
"security alert: token reuse detected, all sessions revoked",
|
|
http.StatusUnauthorized,
|
|
"TOKEN_REUSE_DETECTED",
|
|
))
|
|
return
|
|
}
|
|
if errors.Is(err, core.ErrTokenExpired) {
|
|
core.JSONError(w, core.TokenExpiredError())
|
|
return
|
|
}
|
|
if errors.Is(err, core.ErrTokenRevoked) {
|
|
core.JSONError(w, core.TokenRevokedError())
|
|
return
|
|
}
|
|
if errors.Is(err, core.ErrTokenInvalid) {
|
|
core.JSONError(w, core.TokenInvalidError())
|
|
return
|
|
}
|
|
core.InternalServerError(w, err)
|
|
return
|
|
}
|
|
|
|
h.setRefreshCookie(
|
|
w,
|
|
resp.Tokens.RefreshToken,
|
|
time.Now().Add(refreshCookieMaxAge),
|
|
)
|
|
core.OK(w, resp)
|
|
}
|
|
|
|
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
|
userID := middleware.GetUserID(r.Context())
|
|
if userID == "" {
|
|
core.Unauthorized(w, "")
|
|
return
|
|
}
|
|
|
|
var req RefreshRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
req = RefreshRequest{}
|
|
}
|
|
refreshToken := h.refreshTokenFromRequest(r, req.RefreshToken)
|
|
|
|
var jti string
|
|
var expiresAt time.Time
|
|
if claims := middleware.GetClaims(r.Context()); claims != nil {
|
|
jti = claims.JTI
|
|
expiresAt = claims.ExpiresAt
|
|
}
|
|
|
|
if err := h.service.Logout(
|
|
r.Context(),
|
|
refreshToken,
|
|
userID,
|
|
jti,
|
|
expiresAt,
|
|
); err != nil {
|
|
if errors.Is(err, core.ErrForbidden) {
|
|
core.Forbidden(w, "cannot revoke another user's token")
|
|
return
|
|
}
|
|
core.InternalServerError(w, err)
|
|
return
|
|
}
|
|
|
|
h.clearRefreshCookie(w)
|
|
core.NoContent(w)
|
|
}
|
|
|
|
func (h *Handler) LogoutAll(w http.ResponseWriter, r *http.Request) {
|
|
userID := middleware.GetUserID(r.Context())
|
|
if userID == "" {
|
|
core.Unauthorized(w, "")
|
|
return
|
|
}
|
|
|
|
if err := h.service.LogoutAll(r.Context(), userID); err != nil {
|
|
core.InternalServerError(w, err)
|
|
return
|
|
}
|
|
|
|
core.NoContent(w)
|
|
}
|
|
|
|
func (h *Handler) GetSessions(w http.ResponseWriter, r *http.Request) {
|
|
userID := middleware.GetUserID(r.Context())
|
|
if userID == "" {
|
|
core.Unauthorized(w, "")
|
|
return
|
|
}
|
|
|
|
sessions, err := h.service.GetActiveSessions(r.Context(), userID)
|
|
if err != nil {
|
|
core.InternalServerError(w, err)
|
|
return
|
|
}
|
|
|
|
core.OK(w, SessionsResponse{Sessions: sessions})
|
|
}
|
|
|
|
func (h *Handler) RevokeSession(w http.ResponseWriter, r *http.Request) {
|
|
userID := middleware.GetUserID(r.Context())
|
|
if userID == "" {
|
|
core.Unauthorized(w, "")
|
|
return
|
|
}
|
|
|
|
sessionID := chi.URLParam(r, "sessionID")
|
|
if sessionID == "" {
|
|
core.BadRequest(w, "session ID required")
|
|
return
|
|
}
|
|
|
|
if err := h.service.RevokeSession(
|
|
r.Context(),
|
|
userID,
|
|
sessionID,
|
|
); err != nil {
|
|
if errors.Is(err, core.ErrNotFound) {
|
|
core.NotFound(w, "session")
|
|
return
|
|
}
|
|
if errors.Is(err, core.ErrForbidden) {
|
|
core.Forbidden(w, "cannot revoke another user's session")
|
|
return
|
|
}
|
|
core.InternalServerError(w, err)
|
|
return
|
|
}
|
|
|
|
core.NoContent(w)
|
|
}
|
|
|
|
func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
|
userID := middleware.GetUserID(r.Context())
|
|
if userID == "" {
|
|
core.Unauthorized(w, "")
|
|
return
|
|
}
|
|
|
|
var req ChangePasswordRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
core.BadRequest(w, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if err := h.validator.Struct(req); err != nil {
|
|
core.BadRequest(w, core.FormatValidationError(err))
|
|
return
|
|
}
|
|
|
|
if err := h.service.ChangePassword(
|
|
r.Context(),
|
|
userID,
|
|
req.CurrentPassword,
|
|
req.NewPassword,
|
|
); err != nil {
|
|
if errors.Is(err, ErrInvalidCredentials) {
|
|
core.JSONError(
|
|
w,
|
|
core.UnauthorizedError("current password is incorrect"),
|
|
)
|
|
return
|
|
}
|
|
core.InternalServerError(w, err)
|
|
return
|
|
}
|
|
|
|
core.NoContent(w)
|
|
}
|
|
|
|
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
|
|
userID := middleware.GetUserID(r.Context())
|
|
if userID == "" {
|
|
core.Unauthorized(w, "")
|
|
return
|
|
}
|
|
|
|
user, err := h.service.GetCurrentUser(r.Context(), userID)
|
|
if err != nil {
|
|
if errors.Is(err, core.ErrNotFound) {
|
|
core.NotFound(w, "user")
|
|
return
|
|
}
|
|
core.InternalServerError(w, err)
|
|
return
|
|
}
|
|
|
|
core.OK(w, user)
|
|
}
|
|
|
|
func (h *Handler) extractIPAddress(r *http.Request) string {
|
|
return middleware.ClientIP(r, h.trustedProxyHops)
|
|
}
|