feat(auth): support API tokens and bearer token authentication

- Add backend services to create, list, and delete API tokens.
- Implement Bearer token authentication to resolve tokens to users.
- Register HTTP routes for managing user tokens under `/account/tokens`.
- Add tests to verify that uploads with valid Bearer tokens associate the upload with the correct user, while invalid tokens fall back to anonymous uploads.
This commit is contained in:
2026-05-31 12:50:13 +03:00
parent 0503fad9af
commit d99f8ee82a
9 changed files with 533 additions and 3 deletions

View File

@@ -2,6 +2,7 @@ package handlers
import (
"net/http"
"strings"
"time"
"warpbox.dev/backend/libs/services"
@@ -122,16 +123,92 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
}
type apiTokenView struct {
ID string
Name string
CreatedAt string
LastUsedAt string
}
type accountData struct {
ID string
Email string
Role string
Tokens []apiTokenView
NewToken string
Error string
}
func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok {
return
}
a.renderPage(w, r, http.StatusOK, "account.html", web.PageData{
a.renderAccount(w, r, http.StatusOK, user, accountData{})
}
// CreateUserToken mints a new personal access token and renders the account
// page with the one-time plaintext shown. The secret is never recoverable after
// this response.
func (a *App) CreateUserToken(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Unable to read form."})
return
}
result, err := a.authService.CreateAPIToken(user.ID, r.FormValue("name"))
if err != nil {
a.logger.Warn("api token create failed", "source", "user_activity", "severity", "warn", "code", 4420, "user_id", user.ID, "error", err.Error())
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Could not create token."})
return
}
a.logger.Info("api token created", "source", "user_activity", "severity", "user_activity", "code", 2420, "user_id", user.ID, "token_id", result.Token.ID)
a.renderAccount(w, r, http.StatusOK, user, accountData{NewToken: result.Plaintext})
}
func (a *App) DeleteUserToken(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok || !a.validateCSRF(w, r) {
return
}
if err := a.authService.DeleteAPIToken(user.ID, r.PathValue("tokenID")); err != nil {
a.logger.Warn("api token delete failed", "source", "user_activity", "severity", "warn", "code", 4421, "user_id", user.ID, "error", err.Error())
}
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
}
func (a *App) renderAccount(w http.ResponseWriter, r *http.Request, status int, user services.User, data accountData) {
tokens, err := a.authService.ListAPITokens(user.ID)
if err != nil {
http.Error(w, "unable to load tokens", http.StatusInternalServerError)
return
}
views := make([]apiTokenView, 0, len(tokens))
for _, token := range tokens {
lastUsed := "Never"
if token.LastUsedAt != nil {
lastUsed = token.LastUsedAt.Format("Jan 2, 2006 15:04")
}
views = append(views, apiTokenView{
ID: token.ID,
Name: token.Name,
CreatedAt: token.CreatedAt.Format("Jan 2, 2006"),
LastUsedAt: lastUsed,
})
}
data.ID = user.ID
data.Email = user.Email
data.Role = user.Role
data.Tokens = views
a.renderPage(w, r, status, "account.html", web.PageData{
Title: "Account settings",
Description: "Manage your Warpbox account.",
CurrentUser: a.authService.PublicUser(user),
Data: user,
Data: data,
})
}
@@ -174,6 +251,17 @@ func (a *App) loginAndRedirect(w http.ResponseWriter, r *http.Request, email, pa
}
func (a *App) currentUser(r *http.Request) (services.User, bool) {
// Personal access tokens via Authorization: Bearer act as their owning user.
// A bearer header is never set by browsers cross-site, so this path is not
// subject to CSRF and intentionally bypasses the session cookie.
if header := r.Header.Get("Authorization"); header != "" {
if raw, ok := strings.CutPrefix(header, "Bearer "); ok {
if user, err := a.authService.UserForAPIToken(raw); err == nil {
return user, true
}
return services.User{}, false
}
}
cookie, err := r.Cookie(userSessionCookieName)
if err != nil {
return services.User{}, false