ultisuite-backend/internal/apitokens/tokens.go
R3D347HR4Y bd7534658a Refactor and enhance unified frontend and API features
- Updated environment configuration to unify frontend for mail and drive under a single service.
- Revised README to reflect changes in frontend setup and routing for the unified application.
- Introduced new API documentation endpoints for better accessibility of API specifications.
- Enhanced drive and mail services with improved handling of file uploads and metadata enrichment.
- Implemented new API token management features, including creation, listing, and revocation of tokens.
- Added tests for new functionalities in drive and mail services to ensure reliability and correctness.
2026-06-07 15:44:30 +02:00

301 lines
7.0 KiB
Go

package apitokens
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const tokenPrefix = "ulti_"
func TokenPrefix() string {
return tokenPrefix
}
var (
ErrNotFound = errors.New("api token not found")
ErrRevoked = errors.New("api token revoked")
ErrExpired = errors.New("api token expired")
)
type PermissionGrant struct {
Resource string `json:"resource"`
Read bool `json:"read"`
Write bool `json:"write"`
}
type MailScope struct {
AllAccounts bool `json:"all_accounts"`
AccountIDs []string `json:"account_ids"`
}
type DriveScope struct {
AllFolders bool `json:"all_folders"`
FolderPaths []string `json:"folder_paths"`
}
type Token struct {
ID string `json:"id"`
Name string `json:"name"`
TokenPrefix string `json:"token_prefix"`
Permissions []PermissionGrant `json:"permissions"`
MailScope MailScope `json:"mail_scope"`
DriveScope DriveScope `json:"drive_scope"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type CreatedToken struct {
Token
TokenSecret string `json:"token"`
}
type AuthContext struct {
TokenID string
UserID string
ExternalID string
Email string
Name string
Permissions []PermissionGrant
MailScope MailScope
DriveScope DriveScope
}
func HashSecret(secret string) []byte {
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
func generateSecret() (string, string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", "", err
}
encoded := base64.RawURLEncoding.EncodeToString(raw)
full := tokenPrefix + encoded
visible := tokenPrefix + encoded[:8]
return full, visible, nil
}
func List(ctx context.Context, db *pgxpool.Pool, externalID string) ([]Token, error) {
rows, err := db.Query(ctx, `
SELECT t.id, t.name, t.token_prefix, t.permissions, t.mail_scope, t.drive_scope,
t.created_at, t.last_used_at, t.expires_at
FROM api_tokens t
JOIN users u ON u.id = t.user_id
WHERE u.external_id = $1 AND t.revoked_at IS NULL
ORDER BY t.created_at DESC
`, externalID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]Token, 0)
for rows.Next() {
item, err := scanToken(rows)
if err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func Create(ctx context.Context, db *pgxpool.Pool, externalID string, name string, permissions []PermissionGrant, mailScope MailScope, driveScope DriveScope, expiresAt *time.Time) (CreatedToken, error) {
secret, prefix, err := generateSecret()
if err != nil {
return CreatedToken{}, err
}
permJSON, err := json.Marshal(permissions)
if err != nil {
return CreatedToken{}, err
}
mailJSON, err := json.Marshal(mailScope)
if err != nil {
return CreatedToken{}, err
}
driveJSON, err := json.Marshal(driveScope)
if err != nil {
return CreatedToken{}, err
}
var item Token
err = db.QueryRow(ctx, `
INSERT INTO api_tokens (
user_id, name, token_prefix, secret_hash, permissions, mail_scope, drive_scope, expires_at
)
VALUES (
(SELECT id FROM users WHERE external_id = $1),
$2, $3, $4, $5, $6, $7, $8
)
RETURNING id, name, token_prefix, permissions, mail_scope, drive_scope, created_at, last_used_at, expires_at
`, externalID, name, prefix, HashSecret(secret), permJSON, mailJSON, driveJSON, expiresAt).Scan(
&item.ID,
&item.Name,
&item.TokenPrefix,
&permJSON,
&mailJSON,
&driveJSON,
&item.CreatedAt,
&item.LastUsedAt,
&item.ExpiresAt,
)
if err != nil {
return CreatedToken{}, err
}
if err := decodeTokenJSON(permJSON, mailJSON, driveJSON, &item); err != nil {
return CreatedToken{}, err
}
return CreatedToken{Token: item, TokenSecret: secret}, nil
}
func Revoke(ctx context.Context, db *pgxpool.Pool, externalID, tokenID string) error {
result, err := db.Exec(ctx, `
UPDATE api_tokens
SET revoked_at = now(), updated_at = now()
WHERE id = $1
AND user_id = (SELECT id FROM users WHERE external_id = $2)
AND revoked_at IS NULL
`, tokenID, externalID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func Authenticate(ctx context.Context, db *pgxpool.Pool, secret string) (*AuthContext, error) {
secret = strings.TrimSpace(secret)
if !strings.HasPrefix(secret, tokenPrefix) {
return nil, fmt.Errorf("not an api token")
}
hash := HashSecret(secret)
row := db.QueryRow(ctx, `
SELECT t.id, u.id::text, u.external_id, u.email, COALESCE(u.name, ''),
t.permissions, t.mail_scope, t.drive_scope, t.expires_at, t.revoked_at
FROM api_tokens t
JOIN users u ON u.id = t.user_id
WHERE t.secret_hash = $1
LIMIT 1
`, hash)
var auth AuthContext
var permJSON, mailJSON, driveJSON []byte
var expiresAt *time.Time
var revokedAt *time.Time
if err := row.Scan(
&auth.TokenID,
&auth.UserID,
&auth.ExternalID,
&auth.Email,
&auth.Name,
&permJSON,
&mailJSON,
&driveJSON,
&expiresAt,
&revokedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if revokedAt != nil {
return nil, ErrRevoked
}
if expiresAt != nil && time.Now().After(*expiresAt) {
return nil, ErrExpired
}
if err := json.Unmarshal(permJSON, &auth.Permissions); err != nil {
return nil, err
}
if err := json.Unmarshal(mailJSON, &auth.MailScope); err != nil {
return nil, err
}
if err := json.Unmarshal(driveJSON, &auth.DriveScope); err != nil {
return nil, err
}
_, _ = db.Exec(ctx, `
UPDATE api_tokens SET last_used_at = now(), updated_at = now() WHERE id = $1
`, auth.TokenID)
return &auth, nil
}
func HasPermission(auth *AuthContext, resource string, write bool) bool {
if auth == nil {
return false
}
for _, grant := range auth.Permissions {
if grant.Resource != resource {
continue
}
if write {
return grant.Write
}
return grant.Read || grant.Write
}
return false
}
func ConstantTimeEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanToken(rows rowScanner) (Token, error) {
var item Token
var permJSON, mailJSON, driveJSON []byte
if err := rows.Scan(
&item.ID,
&item.Name,
&item.TokenPrefix,
&permJSON,
&mailJSON,
&driveJSON,
&item.CreatedAt,
&item.LastUsedAt,
&item.ExpiresAt,
); err != nil {
return Token{}, err
}
if err := decodeTokenJSON(permJSON, mailJSON, driveJSON, &item); err != nil {
return Token{}, err
}
return item, nil
}
func decodeTokenJSON(permJSON, mailJSON, driveJSON []byte, item *Token) error {
if err := json.Unmarshal(permJSON, &item.Permissions); err != nil {
return err
}
if err := json.Unmarshal(mailJSON, &item.MailScope); err != nil {
return err
}
if err := json.Unmarshal(driveJSON, &item.DriveScope); err != nil {
return err
}
return nil
}