feat(backend): implement auth handler, middleware, and router
domain/context.go: extend WithUser/UserFromContext with session ID handler/response.go: respondJSON/respondError with domain→HTTP status mapping (404/403/401/409/400/415/500) handler/middleware.go: Bearer JWT extraction, ParseAccessToken, domain.WithUser injection; aborts with 401 JSON on failure handler/auth_handler.go: Login, Refresh, Logout, ListSessions, TerminateSession handler/router.go: /health, /api/v1/auth routes; login and refresh are public, session routes protected by AuthMiddleware Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
277f42035c
commit
1766dc2b3c
@ -7,18 +7,26 @@ type ctxKey int
|
||||
const userKey ctxKey = iota
|
||||
|
||||
type contextUser struct {
|
||||
ID int16
|
||||
IsAdmin bool
|
||||
ID int16
|
||||
IsAdmin bool
|
||||
SessionID int
|
||||
}
|
||||
|
||||
func WithUser(ctx context.Context, userID int16, isAdmin bool) context.Context {
|
||||
return context.WithValue(ctx, userKey, contextUser{ID: userID, IsAdmin: isAdmin})
|
||||
// WithUser stores user identity and current session ID in ctx.
|
||||
func WithUser(ctx context.Context, userID int16, isAdmin bool, sessionID int) context.Context {
|
||||
return context.WithValue(ctx, userKey, contextUser{
|
||||
ID: userID,
|
||||
IsAdmin: isAdmin,
|
||||
SessionID: sessionID,
|
||||
})
|
||||
}
|
||||
|
||||
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool) {
|
||||
// UserFromContext retrieves user identity from ctx.
|
||||
// Returns zero values if no user is stored.
|
||||
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool, sessionID int) {
|
||||
u, ok := ctx.Value(userKey).(contextUser)
|
||||
if !ok {
|
||||
return 0, false
|
||||
return 0, false, 0
|
||||
}
|
||||
return u.ID, u.IsAdmin
|
||||
return u.ID, u.IsAdmin, u.SessionID
|
||||
}
|
||||
|
||||
140
backend/internal/handler/auth_handler.go
Normal file
140
backend/internal/handler/auth_handler.go
Normal file
@ -0,0 +1,140 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"tanabata/backend/internal/domain"
|
||||
"tanabata/backend/internal/service"
|
||||
)
|
||||
|
||||
// AuthHandler handles all /auth endpoints.
|
||||
type AuthHandler struct {
|
||||
authSvc *service.AuthService
|
||||
}
|
||||
|
||||
// NewAuthHandler creates an AuthHandler backed by authSvc.
|
||||
func NewAuthHandler(authSvc *service.AuthService) *AuthHandler {
|
||||
return &AuthHandler{authSvc: authSvc}
|
||||
}
|
||||
|
||||
// Login handles POST /auth/login.
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, domain.ErrValidation)
|
||||
return
|
||||
}
|
||||
|
||||
pair, err := h.authSvc.Login(c.Request.Context(), req.Name, req.Password, c.GetHeader("User-Agent"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(c, http.StatusOK, gin.H{
|
||||
"access_token": pair.AccessToken,
|
||||
"refresh_token": pair.RefreshToken,
|
||||
"expires_in": pair.ExpiresIn,
|
||||
})
|
||||
}
|
||||
|
||||
// Refresh handles POST /auth/refresh.
|
||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, domain.ErrValidation)
|
||||
return
|
||||
}
|
||||
|
||||
pair, err := h.authSvc.Refresh(c.Request.Context(), req.RefreshToken, c.GetHeader("User-Agent"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(c, http.StatusOK, gin.H{
|
||||
"access_token": pair.AccessToken,
|
||||
"refresh_token": pair.RefreshToken,
|
||||
"expires_in": pair.ExpiresIn,
|
||||
})
|
||||
}
|
||||
|
||||
// Logout handles POST /auth/logout. Requires authentication.
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
_, _, sessionID := domain.UserFromContext(c.Request.Context())
|
||||
|
||||
if err := h.authSvc.Logout(c.Request.Context(), sessionID); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ListSessions handles GET /auth/sessions. Requires authentication.
|
||||
func (h *AuthHandler) ListSessions(c *gin.Context) {
|
||||
userID, _, sessionID := domain.UserFromContext(c.Request.Context())
|
||||
|
||||
list, err := h.authSvc.ListSessions(c.Request.Context(), userID, sessionID)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
type sessionItem struct {
|
||||
ID int `json:"id"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
StartedAt string `json:"started_at"`
|
||||
ExpiresAt any `json:"expires_at"`
|
||||
LastActivity string `json:"last_activity"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
}
|
||||
|
||||
items := make([]sessionItem, len(list.Items))
|
||||
for i, s := range list.Items {
|
||||
var expiresAt any
|
||||
if s.ExpiresAt != nil {
|
||||
expiresAt = s.ExpiresAt.Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
items[i] = sessionItem{
|
||||
ID: s.ID,
|
||||
UserAgent: s.UserAgent,
|
||||
StartedAt: s.StartedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
ExpiresAt: expiresAt,
|
||||
LastActivity: s.LastActivity.Format("2006-01-02T15:04:05Z07:00"),
|
||||
IsCurrent: s.IsCurrent,
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(c, http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"total": list.Total,
|
||||
})
|
||||
}
|
||||
|
||||
// TerminateSession handles DELETE /auth/sessions/:id. Requires authentication.
|
||||
func (h *AuthHandler) TerminateSession(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
respondError(c, domain.ErrValidation)
|
||||
return
|
||||
}
|
||||
|
||||
userID, isAdmin, _ := domain.UserFromContext(c.Request.Context())
|
||||
|
||||
if err := h.authSvc.TerminateSession(c.Request.Context(), userID, isAdmin, id); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
52
backend/internal/handler/middleware.go
Normal file
52
backend/internal/handler/middleware.go
Normal file
@ -0,0 +1,52 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"tanabata/backend/internal/domain"
|
||||
"tanabata/backend/internal/service"
|
||||
)
|
||||
|
||||
// AuthMiddleware validates Bearer JWTs and injects user identity into context.
|
||||
type AuthMiddleware struct {
|
||||
authSvc *service.AuthService
|
||||
}
|
||||
|
||||
// NewAuthMiddleware creates an AuthMiddleware backed by authSvc.
|
||||
func NewAuthMiddleware(authSvc *service.AuthService) *AuthMiddleware {
|
||||
return &AuthMiddleware{authSvc: authSvc}
|
||||
}
|
||||
|
||||
// Handle returns a Gin handler function that enforces authentication.
|
||||
// On success it calls c.Next(); on failure it aborts with 401 JSON.
|
||||
func (m *AuthMiddleware) Handle() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(raw, "Bearer ") {
|
||||
c.JSON(http.StatusUnauthorized, errorBody{
|
||||
Code: domain.ErrUnauthorized.Code(),
|
||||
Message: "authorization header missing or malformed",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
token := strings.TrimPrefix(raw, "Bearer ")
|
||||
|
||||
claims, err := m.authSvc.ParseAccessToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, errorBody{
|
||||
Code: domain.ErrUnauthorized.Code(),
|
||||
Message: "invalid or expired token",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin, claims.SessionID)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
55
backend/internal/handler/response.go
Normal file
55
backend/internal/handler/response.go
Normal file
@ -0,0 +1,55 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"tanabata/backend/internal/domain"
|
||||
)
|
||||
|
||||
// errorBody is the JSON shape returned for all error responses.
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func respondJSON(c *gin.Context, status int, data any) {
|
||||
c.JSON(status, data)
|
||||
}
|
||||
|
||||
// respondError maps a domain error to the appropriate HTTP status and writes
|
||||
// a JSON error body. Unknown errors become 500.
|
||||
func respondError(c *gin.Context, err error) {
|
||||
var de *domain.DomainError
|
||||
if errors.As(err, &de) {
|
||||
c.JSON(domainStatus(de), errorBody{Code: de.Code(), Message: de.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, errorBody{
|
||||
Code: "internal_error",
|
||||
Message: "internal server error",
|
||||
})
|
||||
}
|
||||
|
||||
// domainStatus maps a DomainError sentinel to its HTTP status code per the
|
||||
// error mapping table in docs/GO_PROJECT_STRUCTURE.md.
|
||||
func domainStatus(de *domain.DomainError) int {
|
||||
switch de {
|
||||
case domain.ErrNotFound:
|
||||
return http.StatusNotFound
|
||||
case domain.ErrForbidden:
|
||||
return http.StatusForbidden
|
||||
case domain.ErrUnauthorized:
|
||||
return http.StatusUnauthorized
|
||||
case domain.ErrConflict:
|
||||
return http.StatusConflict
|
||||
case domain.ErrValidation:
|
||||
return http.StatusBadRequest
|
||||
case domain.ErrUnsupportedMIME:
|
||||
return http.StatusUnsupportedMediaType
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
37
backend/internal/handler/router.go
Normal file
37
backend/internal/handler/router.go
Normal file
@ -0,0 +1,37 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// NewRouter builds and returns a configured Gin engine.
|
||||
// Additional handlers will be added here as they are implemented.
|
||||
func NewRouter(auth *AuthMiddleware, authHandler *AuthHandler) *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
|
||||
// Health check — no auth required.
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
|
||||
// Auth endpoints — login and refresh are public; others require a valid token.
|
||||
authGroup := v1.Group("/auth")
|
||||
{
|
||||
authGroup.POST("/login", authHandler.Login)
|
||||
authGroup.POST("/refresh", authHandler.Refresh)
|
||||
|
||||
protected := authGroup.Group("", auth.Handle())
|
||||
{
|
||||
protected.POST("/logout", authHandler.Logout)
|
||||
protected.GET("/sessions", authHandler.ListSessions)
|
||||
protected.DELETE("/sessions/:id", authHandler.TerminateSession)
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user