feat: Add the API token service
Mint, resolve, list and revoke, with the effective role capped at the owner's and recomputed per request rather than frozen at creation. Deleting a user deletes their tokens in the same call, so offboarding is one action. Revoking somebody else's token answers not-found rather than forbidden, since a 403 confirms the credential exists. Also re-exports shared.APITokenMaxDays into server/internal/models, following the existing ValidRole wrapper pattern, since the token service needs it and it was never re-exported.
This commit is contained in:
@@ -7,3 +7,7 @@ type (
|
||||
AlertSettings = shared.AlertSettings
|
||||
SecretsSettings = shared.SecretsSettings
|
||||
)
|
||||
|
||||
// APITokenMaxDays re-exports shared.APITokenMaxDays so server/internal/services
|
||||
// can read the token lifetime cap without importing shared/models directly.
|
||||
func APITokenMaxDays(s *Settings) int { return shared.APITokenMaxDays(s) }
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTokenNotFound = errors.New("token not found")
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
ErrTokenNameTaken = errors.New("a token with that name already exists")
|
||||
ErrTokenRoleTooHigh = errors.New("cannot create a token above your own role")
|
||||
ErrTokenExpiryPolicy = errors.New("expiry exceeds this instance's maximum token lifetime")
|
||||
)
|
||||
|
||||
// TokenPrefix is on every plaintext so a leaked value is recognisable in a log
|
||||
// or a paste, and so a wrong credential fails at the prefix check rather than
|
||||
// as an anonymous 401.
|
||||
const TokenPrefix = "vt_"
|
||||
|
||||
const tokenNameMax = 64
|
||||
|
||||
// roleRank orders the three roles so a token can be capped at its owner's.
|
||||
func roleRank(role string) int {
|
||||
switch role {
|
||||
case models.RoleOwner:
|
||||
return 3
|
||||
case models.RoleAdmin:
|
||||
return 2
|
||||
case models.RoleMember:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// LowerRole returns whichever of the two roles grants less. It is what makes a
|
||||
// token's authority follow its owner: demote the person and the token demotes
|
||||
// with them, because this is recomputed on every request rather than frozen at
|
||||
// creation.
|
||||
func LowerRole(a, b string) string {
|
||||
if roleRank(a) <= roleRank(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// CreateAPIToken mints a token and returns the document plus the plaintext.
|
||||
// The plaintext is the only copy: it is returned once and never stored.
|
||||
func CreateAPIToken(instanceID, userID, name, role string, scopes []string, expiresInDays *int, ip string) (*models.APIToken, string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len(name) > tokenNameMax {
|
||||
return nil, "", fmt.Errorf("token name must be 1 to %d characters", tokenNameMax)
|
||||
}
|
||||
if !models.ValidRole(role) {
|
||||
return nil, "", fmt.Errorf("invalid role %q", role)
|
||||
}
|
||||
if err := ValidScopes(scopes); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
owner, err := GetUserInInstance(instanceID, userID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("user not found")
|
||||
}
|
||||
if roleRank(role) > roleRank(owner.Role) {
|
||||
return nil, "", ErrTokenRoleTooHigh
|
||||
}
|
||||
|
||||
settings, err := GetSettings(instanceID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
maxDays := models.APITokenMaxDays(settings)
|
||||
|
||||
var expiresAt *time.Time
|
||||
switch {
|
||||
case expiresInDays != nil:
|
||||
if *expiresInDays <= 0 {
|
||||
return nil, "", fmt.Errorf("expires_in_days must be positive")
|
||||
}
|
||||
if maxDays > 0 && *expiresInDays > maxDays {
|
||||
return nil, "", fmt.Errorf("%w: maximum is %d day(s)", ErrTokenExpiryPolicy, maxDays)
|
||||
}
|
||||
t := time.Now().UTC().AddDate(0, 0, *expiresInDays)
|
||||
expiresAt = &t
|
||||
case maxDays > 0:
|
||||
// A policy is set, so a token with no expiry is refused rather than
|
||||
// silently capped: the caller asked for something the instance does not
|
||||
// allow, and quietly giving them something else is worse than a 422.
|
||||
return nil, "", fmt.Errorf("%w: an expiry of at most %d day(s) is required", ErrTokenExpiryPolicy, maxDays)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
existing := db.Col("api_tokens").FindOne(ctx, bson.M{"instance_id": instanceID, "user_id": userID, "name": name})
|
||||
if existing.Err() == nil {
|
||||
return nil, "", ErrTokenNameTaken
|
||||
} else if !errors.Is(existing.Err(), mongo.ErrNoDocuments) {
|
||||
return nil, "", existing.Err()
|
||||
}
|
||||
|
||||
secret, err := generateToken(32)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
plaintext := TokenPrefix + secret
|
||||
|
||||
tok := &models.APIToken{
|
||||
TokenID: uuid.NewString(),
|
||||
InstanceID: instanceID,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
Hint: plaintext[:8],
|
||||
TokenHash: HashToken(plaintext),
|
||||
Role: role,
|
||||
Scopes: scopes,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
CreatedByIP: ip,
|
||||
}
|
||||
|
||||
if _, err := db.Col("api_tokens").InsertOne(ctx, tok); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return tok, plaintext, nil
|
||||
}
|
||||
|
||||
// ResolveAPIToken looks a plaintext up by hash.
|
||||
//
|
||||
// It returns ErrTokenExpired distinctly from ErrTokenNotFound so the auth layer
|
||||
// can say which happened: a forgotten CI job hitting an expired token is worth
|
||||
// seeing in the audit log, and an anonymous 401 hides it.
|
||||
func ResolveAPIToken(plaintext string) (*models.APIToken, error) {
|
||||
if !strings.HasPrefix(plaintext, TokenPrefix) {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var tok models.APIToken
|
||||
err := db.Col("api_tokens").FindOne(ctx, bson.M{"token_hash": HashToken(plaintext)}).Decode(&tok)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tok.Expired(time.Now().UTC()) {
|
||||
return &tok, ErrTokenExpired
|
||||
}
|
||||
return &tok, nil
|
||||
}
|
||||
|
||||
// TouchAPIToken records use, but only when the stored value is more than a
|
||||
// minute stale. Without the check this is a Mongo write on every API call.
|
||||
func TouchAPIToken(tok *models.APIToken) {
|
||||
now := time.Now().UTC()
|
||||
if tok.LastUsedAt != nil && now.Sub(*tok.LastUsedAt) < time.Minute {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, _ = db.Col("api_tokens").UpdateOne(ctx,
|
||||
bson.M{"token_id": tok.TokenID, "instance_id": tok.InstanceID},
|
||||
bson.M{"$set": bson.M{"last_used_at": now}},
|
||||
)
|
||||
tok.LastUsedAt = &now
|
||||
}
|
||||
|
||||
// ListAPITokens returns a user's own tokens, or every token in the instance
|
||||
// when all is true. The caller decides whether all is permitted.
|
||||
func ListAPITokens(instanceID string, userID string, all bool) ([]models.APIToken, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
filter := bson.M{"instance_id": instanceID}
|
||||
if !all {
|
||||
filter["user_id"] = userID
|
||||
}
|
||||
cursor, err := db.Col("api_tokens").Find(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var tokens []models.APIToken
|
||||
if err := cursor.All(ctx, &tokens); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tokens == nil {
|
||||
tokens = []models.APIToken{}
|
||||
}
|
||||
|
||||
// Join the owning email so an admin's list names people rather than UUIDs.
|
||||
users, err := ListUsers(instanceID)
|
||||
if err == nil {
|
||||
byID := make(map[string]string, len(users))
|
||||
for _, u := range users {
|
||||
byID[u.UserID] = u.Email
|
||||
}
|
||||
for i := range tokens {
|
||||
tokens[i].UserEmail = byID[tokens[i].UserID]
|
||||
}
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// RevokeAPIToken deletes a token. A member may revoke only their own; owner and
|
||||
// admin may revoke any token in the instance.
|
||||
func RevokeAPIToken(instanceID, tokenID string, requester *models.User) (*models.APIToken, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var tok models.APIToken
|
||||
err := db.Col("api_tokens").FindOne(ctx, bson.M{"instance_id": instanceID, "token_id": tokenID}).Decode(&tok)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
elevated := requester.Role == models.RoleOwner || requester.Role == models.RoleAdmin
|
||||
if tok.UserID != requester.UserID && !elevated {
|
||||
// Not 403: confirming the token exists tells a member about somebody
|
||||
// else's credential. Same argument as admin's customer endpoints.
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
|
||||
if _, err := db.Col("api_tokens").DeleteOne(ctx, bson.M{"instance_id": instanceID, "token_id": tokenID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tok, nil
|
||||
}
|
||||
|
||||
// DeleteTokensForUser removes every token belonging to a user. Offboarding is
|
||||
// one action, not two: a token that outlives its owner is an access path with
|
||||
// nobody attached to it.
|
||||
func DeleteTokensForUser(instanceID, userID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err := db.Col("api_tokens").DeleteMany(ctx, bson.M{"instance_id": instanceID, "user_id": userID})
|
||||
return err
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -175,5 +176,14 @@ func DeleteUser(instanceID, userID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err = db.Col("users").DeleteOne(ctx, bson.M{"user_id": userID, "instance_id": instanceID})
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Offboarding is one action. A token outliving its owner is an access path
|
||||
// with nobody attached to it.
|
||||
if err := DeleteTokensForUser(instanceID, userID); err != nil {
|
||||
log.Printf("delete tokens for user %s: %v", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user