Added GET /lists, POST /list/items/{id} and GET /list/{id} #16

Merged
robin merged 4 commits from dev into main 2026-08-21 14:49:21 +02:00
5 changed files with 102 additions and 2 deletions
Showing only changes of commit 5e23d9cc9f - Show all commits
+63 -1
View File
@@ -60,7 +60,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
// @Success 200 {object} domain.TokenPair
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 500 {object} response.ErrorResponse "failed to refresh token"
// @Router /api/v1/refresh [post]
// @Router /api/v1/login/refresh [post]
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -81,3 +81,65 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
response.JSON(ctx, w, http.StatusOK, tokens)
}
// Logout handles logging out a user
//
// @Summary Logout route
// @Description Logout current user
// @Tags Authorization
// @Accept json
// @Produce json
// @Success 200 {object} nil
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 500 {object} response.ErrorResponse "failed to logout"
// @Router /api/v1/logout [post]
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
payload, err := request.DecodeJSON[request.LogoutPayload](r)
if err != nil {
slog.ErrorContext(ctx, "failed to decode logout payload", slog.Any("error", err))
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body")
return
}
err = h.AuthService.Logout(ctx, payload.RefreshToken)
if err != nil {
slog.ErrorContext(ctx, "failed to logout", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to logout")
return
}
response.JSON(ctx, w, http.StatusOK, nil)
}
// LogoutAllDevices handles logging out a user on all devices
//
// @Summary Logout all route
// @Description Logout user from all devices (revokes all refresh tokens)
// @Tags Authorization
// @Accept json
// @Produce json
// @Success 200 {object} nil
// @Error 401 {object} response.ErrorResponse "failed to get auth context"
// @Error 500 {object} response.ErrorResponse "failed to logout"
// @Router /api/v1/logout/all [post]
func (h *AuthHandler) LogoutAllDevices(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authContext, err := domain.GetAuthContext(ctx)
if err != nil {
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
response.Error(ctx, w, http.StatusUnauthorized, "failed to get auth context")
return
}
err = h.AuthService.LogoutAllDevices(ctx, authContext.UserID)
if err != nil {
slog.ErrorContext(ctx, "failed to logout", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to logout")
return
}
response.JSON(ctx, w, http.StatusOK, nil)
}
+4
View File
@@ -8,3 +8,7 @@ type LoginPayload struct {
type RefreshPayload struct {
RefreshToken string `json:"refresh_token"`
}
type LogoutPayload struct {
RefreshToken string `json:"refresh_token"`
}
+4 -1
View File
@@ -33,8 +33,11 @@ func NewMux(cfg Config) http.Handler {
apiMux := http.NewServeMux()
apiMux.HandleFunc("/", handler.DefaultHandler)
apiMux.HandleFunc("GET /health", handler.HealthHandler)
apiMux.HandleFunc("POST /login", authHandler.Login)
apiMux.HandleFunc("POST /auth/refresh", authHandler.Refresh)
apiMux.HandleFunc("POST /login/refresh", authHandler.Refresh)
apiMux.HandleFunc("POST /logout", authHandler.Logout)
apiMux.HandleFunc("POST /logout/all", authHandler.LogoutAllDevices)
apiMux.Handle("POST /users", protected(userHandler.CreateUser))
+12
View File
@@ -19,6 +19,8 @@ type AuthRepository interface {
GetUserByEmail(ctx context.Context, email string) (*AuthUser, error)
StoreRefreshToken(ctx context.Context, userID string, tokenHash string, expiresAt time.Time) error
ConsumeRefreshToken(ctx context.Context, tokenHash string) (string, error)
RevokeRefreshToken(ctx context.Context, tokenHash string) error
RevokeRefreshTokens(ctx context.Context, userID string) error
}
type AuthService struct {
@@ -102,6 +104,16 @@ func (s *AuthService) Refresh(ctx context.Context, refreshToken string) (TokenPa
return s.issueTokens(ctx, authUser)
}
func (s *AuthService) Logout(ctx context.Context, refreshToken string) error {
tokenHash := hashToken(refreshToken)
return s.repo.RevokeRefreshToken(ctx, tokenHash)
}
func (s *AuthService) LogoutAllDevices(ctx context.Context, userID string) error {
return s.repo.RevokeRefreshTokens(ctx, userID)
}
type JWTClaims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
+19
View File
@@ -57,6 +57,7 @@ func (r *AuthRepo) StoreRefreshToken(ctx context.Context, userID string, tokenHa
return nil
}
func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (string, error) {
var userID string
err := r.db.QueryRowContext(ctx,
@@ -72,3 +73,21 @@ func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (s
return userID, nil
}
func (r *AuthRepo) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE token_hash = $1", tokenHash)
if err != nil {
return fmt.Errorf("failed to revoke refresh token: %w", err)
}
return nil
}
func (r *AuthRepo) RevokeRefreshTokens(ctx context.Context, userID string) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE user_id = $1", userID)
if err != nil {
return fmt.Errorf("failed to revoke refresh tokens: %w", err)
}
return nil
}