Auth Package (internal/auth/): - Service: main auth orchestrator with multi-provider support - LocalProvider: username/password auth with bcrypt hashing - LDAPProvider: LDAP/Active Directory authentication with: - Service account bind for user search - User bind for password verification - Automatic user provisioning on first login - Group membership to role synchronization - SessionManager: token-based session lifecycle - Middleware: Gin middleware for route protection - API: REST endpoints for login/logout/register Security Features: - bcrypt with cost factor 12 for password hashing - Secure random 32-byte session tokens - HTTP-only session cookies with SameSite=Lax - Bearer token support for API clients - Session expiration and cleanup - Account disable with session invalidation API Endpoints: - POST /auth/login - Authenticate and get session - POST /auth/logout - Invalidate current session - POST /auth/logout/all - Invalidate all user sessions - POST /auth/register - Create account (if enabled) - GET /auth/me - Get current user info - PUT /auth/me - Update profile - PUT /auth/me/password - Change password 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
284 lines
7.2 KiB
Go
284 lines
7.2 KiB
Go
package auth
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// API provides HTTP handlers for authentication endpoints.
|
|
type API struct {
|
|
auth *Service
|
|
middleware *Middleware
|
|
}
|
|
|
|
// NewAPI creates a new auth API handler.
|
|
func NewAPI(auth *Service) *API {
|
|
return &API{
|
|
auth: auth,
|
|
middleware: NewMiddleware(auth),
|
|
}
|
|
}
|
|
|
|
// RegisterRoutes registers auth routes on a router group.
|
|
func (a *API) RegisterRoutes(group *gin.RouterGroup) {
|
|
// Public routes
|
|
group.POST("/login", a.handleLogin)
|
|
group.POST("/logout", a.handleLogout)
|
|
|
|
if a.auth.config.AllowRegistration {
|
|
group.POST("/register", a.handleRegister)
|
|
}
|
|
|
|
// Authenticated routes
|
|
authenticated := group.Group("")
|
|
authenticated.Use(a.middleware.Required())
|
|
{
|
|
authenticated.GET("/me", a.handleGetCurrentUser)
|
|
authenticated.PUT("/me", a.handleUpdateCurrentUser)
|
|
authenticated.PUT("/me/password", a.handleChangePassword)
|
|
authenticated.POST("/logout/all", a.handleLogoutAll)
|
|
}
|
|
}
|
|
|
|
// Middleware returns the auth middleware for use in other routes.
|
|
func (a *API) Middleware() *Middleware {
|
|
return a.middleware
|
|
}
|
|
|
|
// LoginRequest represents a login request.
|
|
type LoginRequest struct {
|
|
Username string `json:"username" binding:"required"`
|
|
Password string `json:"password" binding:"required"`
|
|
}
|
|
|
|
// LoginResponse represents a successful login response.
|
|
type LoginResponse struct {
|
|
Token string `json:"token"`
|
|
ExpiresAt string `json:"expiresAt"`
|
|
User UserResponse `json:"user"`
|
|
}
|
|
|
|
// UserResponse represents user info in API responses.
|
|
type UserResponse struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email,omitempty"`
|
|
AuthProvider string `json:"authProvider"`
|
|
Roles []string `json:"roles,omitempty"`
|
|
CreatedAt string `json:"createdAt"`
|
|
LastLogin string `json:"lastLogin,omitempty"`
|
|
}
|
|
|
|
func (a *API) handleLogin(c *gin.Context) {
|
|
var req LoginRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
|
|
session, user, err := a.auth.Login(
|
|
c.Request.Context(),
|
|
req.Username,
|
|
req.Password,
|
|
c.ClientIP(),
|
|
c.GetHeader("User-Agent"),
|
|
)
|
|
|
|
if err != nil {
|
|
status := http.StatusUnauthorized
|
|
message := "invalid credentials"
|
|
|
|
if errors.Is(err, ErrUserDisabled) {
|
|
message = "account is disabled"
|
|
}
|
|
|
|
c.JSON(status, gin.H{"error": message})
|
|
return
|
|
}
|
|
|
|
// Set session cookie
|
|
a.middleware.SetSessionCookie(c, session)
|
|
|
|
// Get user roles
|
|
roles, _ := a.auth.db.GetUserRoles(c.Request.Context(), user.ID)
|
|
roleNames := make([]string, len(roles))
|
|
for i, r := range roles {
|
|
roleNames[i] = r.Name
|
|
}
|
|
|
|
c.JSON(http.StatusOK, LoginResponse{
|
|
Token: session.Token,
|
|
ExpiresAt: session.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
|
User: UserResponse{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
Email: user.Email,
|
|
AuthProvider: string(user.AuthProvider),
|
|
Roles: roleNames,
|
|
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
LastLogin: user.LastLogin.Format("2006-01-02T15:04:05Z"),
|
|
},
|
|
})
|
|
}
|
|
|
|
func (a *API) handleLogout(c *gin.Context) {
|
|
// Try to get token from request
|
|
token := ""
|
|
|
|
// Check Authorization header
|
|
if auth := c.GetHeader("Authorization"); len(auth) > 7 && auth[:7] == "Bearer " {
|
|
token = auth[7:]
|
|
}
|
|
|
|
// Check cookie
|
|
if token == "" {
|
|
if cookie, err := c.Cookie(a.auth.config.SessionCookieName); err == nil {
|
|
token = cookie
|
|
}
|
|
}
|
|
|
|
if token != "" {
|
|
a.auth.Logout(c.Request.Context(), token)
|
|
}
|
|
|
|
// Clear session cookie
|
|
a.middleware.ClearSessionCookie(c)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
|
}
|
|
|
|
func (a *API) handleLogoutAll(c *gin.Context) {
|
|
user := MustGetUser(c)
|
|
|
|
if err := a.auth.LogoutAll(c.Request.Context(), user.ID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to logout"})
|
|
return
|
|
}
|
|
|
|
// Clear session cookie
|
|
a.middleware.ClearSessionCookie(c)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "all sessions logged out"})
|
|
}
|
|
|
|
// RegisterRequest represents a registration request.
|
|
type RegisterRequest struct {
|
|
Username string `json:"username" binding:"required,min=3,max=32"`
|
|
Password string `json:"password" binding:"required,min=8"`
|
|
Email string `json:"email" binding:"omitempty,email"`
|
|
}
|
|
|
|
func (a *API) handleRegister(c *gin.Context) {
|
|
var req RegisterRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
|
|
user, err := a.auth.Register(c.Request.Context(), req.Username, req.Password, req.Email)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
message := "registration failed"
|
|
|
|
if errors.Is(err, ErrUsernameExists) {
|
|
status = http.StatusConflict
|
|
message = "username already exists"
|
|
}
|
|
|
|
c.JSON(status, gin.H{"error": message})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, UserResponse{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
Email: user.Email,
|
|
AuthProvider: string(user.AuthProvider),
|
|
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
|
|
func (a *API) handleGetCurrentUser(c *gin.Context) {
|
|
user := MustGetUser(c)
|
|
|
|
// Get user roles
|
|
roles, _ := a.auth.db.GetUserRoles(c.Request.Context(), user.ID)
|
|
roleNames := make([]string, len(roles))
|
|
for i, r := range roles {
|
|
roleNames[i] = r.Name
|
|
}
|
|
|
|
lastLogin := ""
|
|
if !user.LastLogin.IsZero() {
|
|
lastLogin = user.LastLogin.Format("2006-01-02T15:04:05Z")
|
|
}
|
|
|
|
c.JSON(http.StatusOK, UserResponse{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
Email: user.Email,
|
|
AuthProvider: string(user.AuthProvider),
|
|
Roles: roleNames,
|
|
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
LastLogin: lastLogin,
|
|
})
|
|
}
|
|
|
|
// UpdateUserRequest represents a profile update request.
|
|
type UpdateUserRequest struct {
|
|
Email string `json:"email" binding:"omitempty,email"`
|
|
}
|
|
|
|
func (a *API) handleUpdateCurrentUser(c *gin.Context) {
|
|
user := MustGetUser(c)
|
|
|
|
var req UpdateUserRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
|
|
user.Email = req.Email
|
|
|
|
if err := a.auth.UpdateUser(c.Request.Context(), user); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update profile"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "profile updated"})
|
|
}
|
|
|
|
// ChangePasswordRequest represents a password change request.
|
|
type ChangePasswordRequest struct {
|
|
CurrentPassword string `json:"currentPassword" binding:"required"`
|
|
NewPassword string `json:"newPassword" binding:"required,min=8"`
|
|
}
|
|
|
|
func (a *API) handleChangePassword(c *gin.Context) {
|
|
user := MustGetUser(c)
|
|
|
|
var req ChangePasswordRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
|
|
err := a.auth.ChangePassword(c.Request.Context(), user.ID, req.CurrentPassword, req.NewPassword)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
message := "failed to change password"
|
|
|
|
if errors.Is(err, ErrInvalidCredentials) {
|
|
status = http.StatusUnauthorized
|
|
message = "current password is incorrect"
|
|
}
|
|
|
|
c.JSON(status, gin.H{"error": message})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "password changed"})
|
|
}
|