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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user