[server][auth] Store keyID+sha256(key) for API keys (#9)

This commit is contained in:
Abhishek Shroff
2025-07-14 09:14:35 +05:30
parent 4e87878f88
commit f70d4bfb57
8 changed files with 122 additions and 112 deletions
@@ -10,6 +10,7 @@ import (
"codeberg.org/shroff/phylum/server/internal/core"
"codeberg.org/shroff/phylum/server/internal/db"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
@@ -41,19 +42,12 @@ func getAuth(c *gin.Context) (auth.Auth, error) {
db := db.Get(c.Request.Context())
if header := c.Request.Header.Get("Authorization"); header == "" {
if cookie, err := c.Request.Cookie("api_key"); err == nil {
apiKey := cookie.Value
if a, err := auth.ReadAPIKey(db, apiKey); err == nil {
return a, nil
} else {
if errors.Is(err, auth.ErrCredentialsInvalid) {
err = errCredentialsInvalid
}
return nil, err
}
encodedKey := cookie.Value
return readAPIKey(db, encodedKey)
} else if err != http.ErrNoCookie {
return nil, err
}
return nil, errAuthRequired
return nil, errCredentialsInvalid
} else if authHeader, ok := checkAuthHeader(header, "basic"); ok {
if email, password, ok := decodeBasicAuth(authHeader); ok {
if a, err := auth.VerifyUserPassword(db, email, password); err == nil {
@@ -65,8 +59,21 @@ func getAuth(c *gin.Context) (auth.Auth, error) {
return nil, err
}
}
} else if apiKey, ok := checkAuthHeader(header, "api-key"); ok {
if a, err := auth.ReadAPIKey(db, apiKey); err == nil {
} else if encodedKey, ok := checkAuthHeader(header, "api-key"); ok {
return readAPIKey(db, encodedKey)
}
return nil, errCredentialsInvalid
}
func readAPIKey(db db.Handler, encodedKey string) (auth.Auth, error) {
if b, err := base64.StdEncoding.DecodeString(encodedKey); err != nil {
return nil, err
} else if len(b) < 16 {
return nil, errCredentialsInvalid
} else {
keyID, _ := uuid.FromBytes(b[:16])
key := string(b[16:])
if a, err := auth.ReadAPIKey(db, keyID, key); err == nil {
return a, nil
} else {
if errors.Is(err, auth.ErrCredentialsInvalid) {
@@ -75,12 +82,11 @@ func getAuth(c *gin.Context) (auth.Auth, error) {
return nil, err
}
}
return nil, errAuthRequired
}
func checkAuthHeader(header, prefix string) (string, bool) {
prefix = prefix + " "
if len(header) < len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
prefix = strings.ToLower(prefix) + " "
if len(header) < len(prefix) || !strings.EqualFold(strings.ToLower(header[:len(prefix)]), prefix) {
return "", false
}
return header[len(prefix):], true
+24 -56
View File
@@ -1,6 +1,7 @@
package auth
import (
"encoding/base64"
"errors"
"net/http"
"net/url"
@@ -14,6 +15,7 @@ import (
"codeberg.org/shroff/phylum/server/internal/db"
"codeberg.org/shroff/phylum/server/internal/mail"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type PasswordConfigResponse struct {
@@ -126,29 +128,9 @@ func handleTokenLogin(c *gin.Context) {
panic(core.NewError(http.StatusBadRequest, "missing_params", "login token not specified"))
}
var apiKey string
var response responses.Bootstrap
if err := db.RunInTx(c.Request.Context(), func(db db.TxHandler) error {
if a, t, err := auth.PerformTokenLogin(db, params.LoginToken); err != nil {
if errors.Is(err, auth.ErrTokenInvalid) {
return core.NewError(http.StatusBadRequest, "token_invalid", "login token is invalid")
}
return err
} else if r, err := my.Bootstrap(c.Request.Context(), a, 0); err != nil {
return err
} else {
apiKey = t
response = r
return nil
}
}); err != nil {
panic(err)
} else {
c.JSON(http.StatusOK, responses.Login{
APIKey: apiKey,
Bootstrap: response,
})
}
loginAndSendResponse(c, func(db db.TxHandler) (auth.Auth, uuid.UUID, string, error) {
return auth.PerformTokenLogin(db, params.LoginToken)
})
}
func handlePasswordAuth(c *gin.Context) {
@@ -158,29 +140,9 @@ func handlePasswordAuth(c *gin.Context) {
panic(core.NewError(http.StatusBadRequest, "missing_params", "Email or password not specified"))
}
var token string
var response responses.Bootstrap
if err := db.RunInTx(c.Request.Context(), func(db db.TxHandler) error {
if user, t, err := auth.PerformPasswordLogin(db, params.Email, params.Password); err != nil {
if errors.Is(err, auth.ErrCredentialsInvalid) {
return core.NewError(http.StatusUnauthorized, "credentials_invalid", "invalid credentials")
}
return err
} else if r, err := my.Bootstrap(c.Request.Context(), user, 0); err != nil {
return err
} else {
token = t
response = r
return nil
}
}); err != nil {
panic(err)
} else {
c.JSON(http.StatusOK, responses.Login{
APIKey: token,
Bootstrap: response,
})
}
loginAndSendResponse(c, func(db db.TxHandler) (auth.Auth, uuid.UUID, string, error) {
return auth.PerformPasswordLogin(db, params.Email, params.Password)
})
}
func handleRequestPasswordReset(c *gin.Context) {
@@ -217,31 +179,37 @@ func handleResetPassword(c *gin.Context) {
panic(core.NewError(http.StatusBadRequest, "missing_params", "Missing Parameters"))
}
var token string
loginAndSendResponse(c, func(db db.TxHandler) (auth.Auth, uuid.UUID, string, error) {
return auth.ResetUserPassword(db, params.Email, params.Token, params.Password)
})
}
func loginAndSendResponse(c *gin.Context, loginFn func(db.TxHandler) (auth.Auth, uuid.UUID, string, error)) {
var err error
var keyID uuid.UUID
var key string
var response responses.Bootstrap
if err := db.RunInTx(c.Request.Context(), func(db db.TxHandler) error {
if user, t, err := auth.ResetUserPassword(db, params.Email, params.Token, params.Password); err != nil {
if errors.Is(err, auth.ErrTokenInvalid) {
return core.NewError(http.StatusBadRequest, "token_invalid", "reset token is invalid")
} else if e, ok := err.(*auth.PasswordStrengthError); ok {
return core.NewError(http.StatusBadRequest, "invalid_password", e.Reason)
var a auth.Auth
if a, keyID, key, err = loginFn(db); err != nil {
if errors.Is(err, auth.ErrCredentialsInvalid) {
return core.NewError(http.StatusUnauthorized, "credentials_invalid", "invalid credentials")
}
return err
} else if r, err := my.Bootstrap(c.Request.Context(), user, 0); err != nil {
} else if r, err := my.Bootstrap(c.Request.Context(), a, 0); err != nil {
return err
} else {
token = t
response = r
return nil
}
}); err != nil {
panic(err)
} else {
b := append(keyID[:], []byte(key)...)
c.JSON(http.StatusOK, responses.Login{
APIKey: token,
APIKey: base64.StdEncoding.EncodeToString(b),
Bootstrap: response,
})
}
}
+18 -12
View File
@@ -1,17 +1,20 @@
package auth
import (
"crypto/sha256"
"errors"
"time"
"codeberg.org/shroff/phylum/server/internal/db"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func ReadAPIKey(db db.Handler, apiKey string) (Auth, error) {
const q = `SELECT k.expires, u.id, u.permissions, u.home, k.scopes FROM api_keys k JOIN users u ON k.user_id = u.id WHERE k.key = $1; `
row := db.QueryRow(q, apiKey)
func ReadAPIKey(db db.Handler, keyID uuid.UUID, key string) (Auth, error) {
const q = `SELECT k.expires, u.id, u.permissions, u.home, k.scopes FROM api_keys k JOIN users u ON k.user_id = u.id WHERE k.id = $1 AND k.hash = $2`
hash := sha256.Sum256([]byte(key))
row := db.QueryRow(q, keyID, hash[:])
var expires pgtype.Timestamp
var auth auth
@@ -27,25 +30,28 @@ func ReadAPIKey(db db.Handler, apiKey string) (Auth, error) {
return auth, nil
}
func insertAPIKey(db db.TxHandler, userID int32, validity time.Duration, keyName string, scopes []string) (string, error) {
const q = `INSERT INTO api_keys(key, expires, user_id, name, scopes) VALUES (@key::TEXT, @expires, @user_id::INT, NULLIF(@key_name, ''), @scopes::TEXT[])`
func GenerateAPIKey(db db.TxHandler, userID int32, validity time.Duration, description string, scopes []string) (uuid.UUID, string, error) {
const q = `INSERT INTO api_keys(id, expires, user_id, hash, description, scopes) VALUES (@id, @expires, @user_id, @hash, @description, @scopes)`
id, _ := uuid.NewV7()
key := generateSecureKey(apiKeyLength)
expires := pgtype.Timestamp{}
if validity != 0 {
expires.Valid = true
expires.Time = time.Now().Add(validity)
}
hash := sha256.Sum256([]byte(key))
args := pgx.NamedArgs{
"key": key,
"expires": expires,
"user_id": userID,
"key_name": keyName,
"scopes": scopes,
"id": id,
"expires": expires,
"user_id": userID,
"hash": hash[:],
"description": description,
"scopes": scopes,
}
if _, err := db.Exec(q, args); err != nil {
return "", err
return uuid.Nil, "", err
} else {
return key, nil
return id, key, nil
}
}
+6 -6
View File
@@ -6,6 +6,7 @@ import (
"codeberg.org/shroff/phylum/server/internal/core"
"codeberg.org/shroff/phylum/server/internal/db"
"github.com/google/uuid"
)
type PasswordBackend interface {
@@ -37,12 +38,11 @@ func VerifyUserPassword(d db.Handler, email, password string) (Auth, error) {
return auth, err
}
func PerformPasswordLogin(db db.TxHandler, email, password string) (Auth, string, error) {
if auth, err := VerifyUserPassword(db, email, password); err != nil {
return nil, "", err
} else if token, err := insertAPIKey(db, auth.UserID(), 0, "", []string{"*"}); err != nil {
return nil, "", err
func PerformPasswordLogin(db db.TxHandler, email, password string) (auth Auth, keyID uuid.UUID, key string, err error) {
if auth, err = VerifyUserPassword(db, email, password); err != nil {
return
} else {
return auth, token, nil
keyID, key, err = GenerateAPIKey(db, auth.UserID(), 0, "", []string{"*"})
return
}
}
+13 -13
View File
@@ -7,6 +7,7 @@ import (
"codeberg.org/shroff/phylum/server/internal/core"
"codeberg.org/shroff/phylum/server/internal/db"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
@@ -32,13 +33,14 @@ func CreateResetToken(db db.TxHandler, email string) (core.User, string, error)
}
func ResetUserPassword(db db.TxHandler, email, resetToken, password string) (Auth, string, error) {
func ResetUserPassword(db db.TxHandler, email, resetToken, password string) (a Auth, keyID uuid.UUID, key string, err error) {
if !passwordBackend.SupportsPasswordUpdate() {
return nil, "", errors.New("password update not supported")
err = errors.New("password update not supported")
return
}
user, err := core.UserByEmail(db, email)
if err != nil {
return nil, "", err
return
}
// UpdateUserPassword will ensure the password strength
@@ -46,7 +48,7 @@ func ResetUserPassword(db db.TxHandler, email, resetToken, password string) (Aut
// TODO: Are there perf implications for this in case of malicious actors?
err = UpdateUserPassword(db, email, password)
if err != nil {
return nil, "", err
return
}
const q = `DELETE FROM reset_tokens WHERE user_id = @user_id::INT AND token = @token::TEXT RETURNING expires`
@@ -56,27 +58,25 @@ func ResetUserPassword(db db.TxHandler, email, resetToken, password string) (Aut
}
row := db.QueryRow(q, args)
var expires time.Time
if err := row.Scan(&expires); err != nil {
if err = row.Scan(&expires); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
err = ErrTokenInvalid
}
return nil, "", err
return
}
if time.Now().After(expires) {
return nil, "", ErrTokenInvalid
err = ErrTokenInvalid
return
}
apiKey, err := insertAPIKey(db, user.ID, 0, "", []string{"*"})
if err != nil {
return nil, "", err
}
auth := auth{
a = auth{
userID: user.ID,
homeID: user.Home,
userPermissions: user.Permissions,
scopes: []string{"*"},
}
return auth, apiKey, err
keyID, key, err = GenerateAPIKey(db, user.ID, 0, "", []string{"*"})
return
}
func UpdateUserPassword(db db.TxHandler, email, password string) error {
+13 -10
View File
@@ -6,31 +6,34 @@ import (
"codeberg.org/shroff/phylum/server/internal/core"
"codeberg.org/shroff/phylum/server/internal/db"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func PerformTokenLogin(db db.TxHandler, token string) (Auth, string, error) {
func PerformTokenLogin(db db.TxHandler, token string) (a Auth, keyID uuid.UUID, key string, err error) {
row := db.QueryRow("DELETE FROM pending_logins WHERE token = $1 AND user_id IS NOT NULL RETURNING user_id, expires", token)
var user core.User
var userID int32
var expires time.Time
if err := row.Scan(&userID, &expires); err != nil {
if err = row.Scan(&userID, &expires); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
err = ErrTokenInvalid
}
return nil, "", err
return
} else if expires.Before(time.Now()) {
return nil, "", ErrTokenInvalid
} else if user, err := core.UserByID(db, userID); err != nil {
return nil, "", err
} else if token, err := insertAPIKey(db, userID, 0, "", []string{"*"}); err != nil {
return nil, "", err
err = ErrTokenInvalid
return
} else if user, err = core.UserByID(db, userID); err != nil {
return
} else if keyID, key, err = GenerateAPIKey(db, userID, 0, "", []string{"*"}); err != nil {
return
} else {
auth := auth{
a = auth{
userID: user.ID,
homeID: user.Home,
userPermissions: user.Permissions,
scopes: []string{"*"},
}
return auth, token, err
return
}
}
@@ -0,0 +1,27 @@
DROP TABLE api_keys;
CREATE TABLE api_keys(
id UUID NOT NULL PRIMARY KEY,
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires TIMESTAMPTZ,
user_id INT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE,
hash BYTEA NOT NULL,
description TEXT,
scopes TEXT[] NOT NULL DEFAULT '{"*"}'
);
---- create above / drop below ----
DROP TABLE api_keys;
CREATE TABLE api_keys(
key TEXT NOT NULL PRIMARY KEY,
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires TIMESTAMPTZ,
user_id INT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE
name TEXT NOT NULL,
scopes TEXT[] NOT NULL DEFAULT '{"*"}'
);
CREATE UNIQUE INDEX unique_named_user_key ON api_keys(user_id, name);