- 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.
301 lines
7.0 KiB
Go
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
|
|
}
|