mirror of
https://codeberg.org/shroff/phylum.git
synced 2026-05-06 20:29:22 -05:00
[server][auth] Store keyID+sha256(key) for API keys (#9)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 +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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user