feat: add API token authentication (closes #87)
- Add api_tokens table migration (007) - Add APIToken model with CRUD operations - Generate tokens with upaas_ prefix + 32 hex chars - Store SHA-256 hash of tokens (not plaintext) - Update APISessionAuth middleware to check Bearer tokens - Add POST/GET/DELETE /api/v1/tokens endpoints - Token creation returns plaintext once; list never exposes it - Expired and revoked tokens are rejected - Tests for creation, listing, deletion, bearer auth, revocation
This commit is contained in:
220
internal/handlers/api_tokens.go
Normal file
220
internal/handlers/api_tokens.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
)
|
||||
|
||||
// apiTokenResponse is the JSON representation of an API token.
|
||||
type apiTokenResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ExpiresAt *string `json:"expiresAt,omitempty"`
|
||||
LastUsedAt *string `json:"lastUsedAt,omitempty"`
|
||||
}
|
||||
|
||||
// apiTokenCreateResponse includes the plaintext token (shown once).
|
||||
type apiTokenCreateResponse struct {
|
||||
apiTokenResponse
|
||||
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func tokenToAPI(t *models.APIToken) apiTokenResponse {
|
||||
resp := apiTokenResponse{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
|
||||
if t.ExpiresAt.Valid {
|
||||
s := t.ExpiresAt.Time.Format("2006-01-02T15:04:05Z")
|
||||
resp.ExpiresAt = &s
|
||||
}
|
||||
|
||||
if t.LastUsedAt.Valid {
|
||||
s := t.LastUsedAt.Time.Format("2006-01-02T15:04:05Z")
|
||||
resp.LastUsedAt = &s
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// createTokenRequest is the JSON body for token creation.
|
||||
type createTokenRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// createAndSaveToken generates a token, saves it, and returns
|
||||
// the plaintext and model.
|
||||
func (h *Handlers) createAndSaveToken(
|
||||
ctx context.Context,
|
||||
userID int64,
|
||||
name string,
|
||||
) (string, *models.APIToken, error) {
|
||||
plaintext, err := models.GenerateToken()
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("generating: %w", err)
|
||||
}
|
||||
|
||||
token := models.NewAPIToken(h.db)
|
||||
token.UserID = userID
|
||||
token.Name = name
|
||||
token.TokenHash = database.HashAPIToken(plaintext)
|
||||
|
||||
saveErr := token.Save(ctx)
|
||||
if saveErr != nil {
|
||||
return "", nil, fmt.Errorf("saving: %w", saveErr)
|
||||
}
|
||||
|
||||
return plaintext, token, nil
|
||||
}
|
||||
|
||||
// HandleAPICreateToken returns a handler that creates an API token.
|
||||
func (h *Handlers) HandleAPICreateToken() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
user, err := h.auth.GetCurrentUser(
|
||||
request.Context(), request,
|
||||
)
|
||||
if err != nil || user == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "unauthorized"},
|
||||
http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var req createTokenRequest
|
||||
|
||||
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
||||
if decodeErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "invalid JSON body"},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "name is required"},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
plaintext, token, createErr := h.createAndSaveToken(
|
||||
request.Context(), user.ID, req.Name,
|
||||
)
|
||||
if createErr != nil {
|
||||
h.log.Error("api: token creation failed",
|
||||
"error", createErr)
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "internal error"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request, apiTokenCreateResponse{
|
||||
apiTokenResponse: tokenToAPI(token),
|
||||
Token: plaintext,
|
||||
}, http.StatusCreated)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIListTokens returns a handler that lists API tokens.
|
||||
func (h *Handlers) HandleAPIListTokens() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
user, err := h.auth.GetCurrentUser(
|
||||
request.Context(), request,
|
||||
)
|
||||
if err != nil || user == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "unauthorized"},
|
||||
http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tokens, listErr := models.ListAPITokensByUser(
|
||||
request.Context(), h.db, user.ID,
|
||||
)
|
||||
if listErr != nil {
|
||||
h.log.Error("api: failed to list tokens",
|
||||
"error", listErr)
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "internal error"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]apiTokenResponse, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
result = append(result, tokenToAPI(t))
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request, result, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDeleteToken returns a handler that revokes an API token.
|
||||
func (h *Handlers) HandleAPIDeleteToken() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
user, err := h.auth.GetCurrentUser(
|
||||
request.Context(), request,
|
||||
)
|
||||
if err != nil || user == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "unauthorized"},
|
||||
http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tokenID := chi.URLParam(request, "tokenID")
|
||||
|
||||
token, findErr := models.FindAPIToken(
|
||||
request.Context(), h.db, tokenID,
|
||||
)
|
||||
if findErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "internal error"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if token == nil || token.UserID != user.ID {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "token not found"},
|
||||
http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deleteErr := token.Delete(request.Context())
|
||||
if deleteErr != nil {
|
||||
h.log.Error("api: failed to delete token",
|
||||
"error", deleteErr)
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "internal error"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"status": "deleted"},
|
||||
http.StatusOK)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user