mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Add api keys support (#900)
This commit is contained in:
commit
e68217dbce
@ -99,6 +99,6 @@ RABBITMQ_DEFAULT_PASS=aohohunuhouhuhhoahothonseuhaoensuthoaentsuhha
|
||||
|
||||
# v5 stuff, does absolutely nothing on master (aka: you can delete this)
|
||||
EXTRA_CLAIMS='{"permissions": ["core.read"], "verified": false}'
|
||||
FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "users.delete", "core.read"], "verified": true}'
|
||||
FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "apikeys.read", "apikeys.write", "users.delete", "core.read", "core.write"], "verified": true}'
|
||||
GUEST_CLAIMS='{"permissions": ["core.read"]}'
|
||||
PROTECTED_CLAIMS="permissions,verified"
|
||||
|
4
.github/workflows/auth-hurl.yml
vendored
4
.github/workflows/auth-hurl.yml
vendored
@ -53,8 +53,12 @@ jobs:
|
||||
env:
|
||||
POSTGRES_SERVER: localhost
|
||||
FIRST_USER_CLAIMS: '{"permissions": ["users.read"]}'
|
||||
KEIBI_APIKEY_HURL: 1234apikey
|
||||
KEIBI_APIKEY_HURL_CLAIMS: '{"permissions": ["apikeys.write", "apikeys.read"]}'
|
||||
|
||||
|
||||
- name: Show logs
|
||||
if: failure()
|
||||
working-directory: ./auth
|
||||
run: cat logs
|
||||
|
||||
|
@ -22,6 +22,12 @@ PROTECTED_CLAIMS="permissions"
|
||||
# The url you can use to reach your kyoo instance. This is used during oidc to redirect users to your instance.
|
||||
PUBLIC_URL=http://localhost:8901
|
||||
|
||||
# You can create apikeys at runtime via POST /apikey but you can also have some defined in the env.
|
||||
# Replace $YOURNAME with the name of the key you want (only alpha are valid)
|
||||
# The value will be the apikey (max 128 bytes)
|
||||
# KEIBI_APIKEY_$YOURNAME=oaeushtaoesunthoaensuth
|
||||
# KEIBI_APIKEY_$YOURNAME_CLAIMS='{"permissions": ["users.read"]}'
|
||||
|
||||
# Database things
|
||||
POSTGRES_USER=kyoo
|
||||
POSTGRES_PASSWORD=password
|
||||
|
@ -61,11 +61,11 @@ GET `/users/$id/sessions` can be used by admins to list others session
|
||||
|
||||
```
|
||||
Get `/apikeys`
|
||||
Post `/apikeys` {...nlaims} Create a new api keys with given claims
|
||||
Post `/apikeys` {...claims} Create a new api keys with given claims
|
||||
```
|
||||
|
||||
An api key can be used like an opaque token, calling /jwt with it will return a valid jwt with the claims you specified during the post request to create it.
|
||||
Creating an apikeys requires the `apikey.create` permission, reading them requires the `apikey.read` permission.
|
||||
Creating an apikeys requires the `apikey.write` permission, reading them requires the `apikey.read` permission.
|
||||
|
||||
### OIDC
|
||||
|
||||
|
218
auth/apikey.go
Normal file
218
auth/apikey.go
Normal file
@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgerrcode"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/zoriya/kyoo/keibi/dbc"
|
||||
)
|
||||
|
||||
type ApiKey struct {
|
||||
Id uuid.UUID `json:"id" example:"e05089d6-9179-4b5b-a63e-94dd5fc2a397"`
|
||||
Name string `json:"name" example:"myapp"`
|
||||
CreatedAt time.Time `json:"createAt" example:"2025-03-29T18:20:05.267Z"`
|
||||
LastUsed time.Time `json:"lastUsed" example:"2025-03-29T18:20:05.267Z"`
|
||||
Claims jwt.MapClaims `json:"claims" example:"isAdmin: true"`
|
||||
}
|
||||
|
||||
type ApiKeyWToken struct {
|
||||
ApiKey
|
||||
Token string `json:"token" example:"myapp-lyHzTYm9yi+pkEv3m2tamAeeK7Dj7N3QRP7xv7dPU5q9MAe8tU4ySwYczE0RaMr4fijsA=="`
|
||||
}
|
||||
|
||||
type ApiKeyDto struct {
|
||||
Name string `json:"name" example:"myapp" validate:"alpha"`
|
||||
Claims jwt.MapClaims `json:"claims" example:"isAdmin: true"`
|
||||
}
|
||||
|
||||
func MapDbKey(key *dbc.Apikey) ApiKeyWToken {
|
||||
return ApiKeyWToken{
|
||||
ApiKey: ApiKey{
|
||||
Id: key.Id,
|
||||
Name: key.Name,
|
||||
Claims: key.Claims,
|
||||
CreatedAt: key.CreatedAt,
|
||||
LastUsed: key.LastUsed,
|
||||
},
|
||||
Token: fmt.Sprintf("%s-%s", key.Name, key.Token),
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Create API key
|
||||
// @Description Create a new API key
|
||||
// @Tags apikeys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Jwt[apikeys.write]
|
||||
// @Param key body ApiKeyDto false "Api key info"
|
||||
// @Success 201 {object} ApiKeyWToken
|
||||
// @Failure 409 {object} KError "Duplicated api key"
|
||||
// @Failure 422 {object} KError "Invalid create body"
|
||||
// @Router /keys [post]
|
||||
func (h *Handler) CreateApiKey(c echo.Context) error {
|
||||
err := CheckPermissions(c, []string{"apikeys.write"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var req ApiKeyDto
|
||||
err = c.Bind(&req)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error())
|
||||
}
|
||||
if err = c.Validate(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, conflict := h.config.EnvApiKeys[req.Name]; conflict {
|
||||
return echo.NewHTTPError(409, "An env apikey is already defined with the same name")
|
||||
}
|
||||
|
||||
id := make([]byte, 64)
|
||||
_, err = rand.Read(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var user *int32
|
||||
uid, err := GetCurrentUserId(c)
|
||||
// if err, we probably are using an api key (so no user)
|
||||
if err != nil {
|
||||
u, _ := h.db.GetUser(context.Background(), dbc.GetUserParams{
|
||||
UseId: true,
|
||||
Id: uid,
|
||||
})
|
||||
user = &u[0].User.Pk
|
||||
}
|
||||
|
||||
dbkey, err := h.db.CreateApiKey(context.Background(), dbc.CreateApiKeyParams{
|
||||
Name: req.Name,
|
||||
Token: base64.RawURLEncoding.EncodeToString(id),
|
||||
Claims: req.Claims,
|
||||
CreatedBy: user,
|
||||
})
|
||||
if ErrIs(err, pgerrcode.UniqueViolation) {
|
||||
return echo.NewHTTPError(409, "An apikey with the same name already exists.")
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(201, MapDbKey(&dbkey))
|
||||
}
|
||||
|
||||
// @Summary Delete API key
|
||||
// @Description Delete an existing API key
|
||||
// @Tags apikeys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Jwt[apikeys.write]
|
||||
// @Success 200 {object} ApiKey
|
||||
// @Failure 404 {object} KError "Invalid id"
|
||||
// @Failure 422 {object} KError "Invalid id format"
|
||||
// @Router /keys [delete]
|
||||
func (h *Handler) DeleteApiKey(c echo.Context) error {
|
||||
err := CheckPermissions(c, []string{"apikeys.write"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(422, "Invalid id given: not an uuid")
|
||||
}
|
||||
|
||||
dbkey, err := h.db.DeleteApiKey(context.Background(), id)
|
||||
if err == pgx.ErrNoRows {
|
||||
return echo.NewHTTPError(404, "No apikey found")
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(200, MapDbKey(&dbkey).ApiKey)
|
||||
}
|
||||
|
||||
// @Summary List API keys
|
||||
// @Description List all api keys
|
||||
// @Tags apikeys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Jwt[apikeys.read]
|
||||
// @Success 200 {object} Page[ApiKey]
|
||||
// @Router /keys [get]
|
||||
func (h *Handler) ListApiKey(c echo.Context) error {
|
||||
err := CheckPermissions(c, []string{"apikeys.read"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbkeys, err := h.db.ListApiKeys(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ret []ApiKey
|
||||
for _, key := range dbkeys {
|
||||
ret = append(ret, MapDbKey(&key).ApiKey)
|
||||
}
|
||||
|
||||
for _, key := range h.config.EnvApiKeys {
|
||||
ret = append(ret, key.ApiKey)
|
||||
}
|
||||
|
||||
return c.JSON(200, Page[ApiKey]{
|
||||
Items: ret,
|
||||
This: c.Request().URL.String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) createApiJwt(apikey string) (string, error) {
|
||||
info := strings.SplitN(apikey, "-", 2)
|
||||
if len(info) != 2 {
|
||||
return "", echo.NewHTTPError(http.StatusForbidden, "Invalid api key format")
|
||||
}
|
||||
|
||||
key, fromEnv := h.config.EnvApiKeys[info[0]]
|
||||
if !fromEnv {
|
||||
dbKey, err := h.db.GetApiKey(context.Background(), dbc.GetApiKeyParams{
|
||||
Name: info[0],
|
||||
Token: info[1],
|
||||
})
|
||||
if err == pgx.ErrNoRows {
|
||||
return "", echo.NewHTTPError(http.StatusForbidden, "Invalid api key")
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
go func() {
|
||||
h.db.TouchApiKey(context.Background(), dbKey.Pk)
|
||||
}()
|
||||
|
||||
key = MapDbKey(&dbKey)
|
||||
}
|
||||
|
||||
claims := maps.Clone(key.Claims)
|
||||
claims["username"] = key.Name
|
||||
claims["sub"] = key.Id
|
||||
claims["sid"] = key.Id
|
||||
claims["iss"] = h.config.PublicUrl
|
||||
claims["iat"] = &jwt.NumericDate{
|
||||
Time: time.Now().UTC(),
|
||||
}
|
||||
claims["exp"] = &jwt.NumericDate{
|
||||
Time: time.Now().UTC().Add(time.Hour),
|
||||
}
|
||||
jwt := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
t, err := jwt.SignedString(h.config.JwtPrivateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return t, nil
|
||||
}
|
@ -1,17 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/zoriya/kyoo/keibi/dbc"
|
||||
)
|
||||
|
||||
@ -25,6 +28,7 @@ type Configuration struct {
|
||||
GuestClaims jwt.MapClaims
|
||||
ProtectedClaims []string
|
||||
ExpirationDelay time.Duration
|
||||
EnvApiKeys map[string]ApiKeyWToken
|
||||
}
|
||||
|
||||
var DefaultConfig = Configuration{
|
||||
@ -32,6 +36,7 @@ var DefaultConfig = Configuration{
|
||||
FirstUserClaims: make(jwt.MapClaims),
|
||||
ProtectedClaims: []string{"permissions"},
|
||||
ExpirationDelay: 30 * 24 * time.Hour,
|
||||
EnvApiKeys: make(map[string]ApiKeyWToken),
|
||||
}
|
||||
|
||||
func LoadConfiguration(db *dbc.Queries) (*Configuration, error) {
|
||||
@ -98,5 +103,51 @@ func LoadConfiguration(db *dbc.Queries) (*Configuration, error) {
|
||||
ret.JwtPublicKey = &ret.JwtPrivateKey.PublicKey
|
||||
}
|
||||
|
||||
for _, env := range os.Environ() {
|
||||
if !strings.HasPrefix(env, "KEIBI_APIKEY_"){
|
||||
continue
|
||||
}
|
||||
v := strings.Split(env, "=")
|
||||
if strings.HasSuffix(v[0], "_CLAIMS") {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(v[0], "KEIBI_APIKEY_")
|
||||
cstr := os.Getenv(fmt.Sprintf("KEIBI_APIKEY_%s_CLAIMS", name))
|
||||
|
||||
var claims jwt.MapClaims
|
||||
if cstr != "" {
|
||||
err := json.Unmarshal([]byte(cstr), &claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("missing claims env var KEIBI_APIKEY_%s_CLAIMS", name)
|
||||
}
|
||||
|
||||
name = strings.ToLower(name)
|
||||
ret.EnvApiKeys[name] = ApiKeyWToken{
|
||||
ApiKey: ApiKey{
|
||||
Id: uuid.New(),
|
||||
Name: name,
|
||||
Claims: claims,
|
||||
},
|
||||
Token: v[1],
|
||||
}
|
||||
|
||||
}
|
||||
apikeys, err := db.ListApiKeys(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, key := range apikeys {
|
||||
if _, defined := ret.EnvApiKeys[key.Name]; defined {
|
||||
return nil, fmt.Errorf(
|
||||
"an api key with the name %s is already defined in database. Can't specify a new one via env var",
|
||||
key.Name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return &ret, nil
|
||||
}
|
||||
|
154
auth/dbc/apikeys.sql.go
Normal file
154
auth/dbc/apikeys.sql.go
Normal file
@ -0,0 +1,154 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.28.0
|
||||
// source: apikeys.sql
|
||||
|
||||
package dbc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const createApiKey = `-- name: CreateApiKey :one
|
||||
insert into apikeys(name, token, claims, created_by)
|
||||
values ($1, $2, $3, $4)
|
||||
returning
|
||||
pk, id, name, token, claims, created_by, created_at, last_used
|
||||
`
|
||||
|
||||
type CreateApiKeyParams struct {
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Claims jwt.MapClaims `json:"claims"`
|
||||
CreatedBy *int32 `json:"createdBy"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateApiKey(ctx context.Context, arg CreateApiKeyParams) (Apikey, error) {
|
||||
row := q.db.QueryRow(ctx, createApiKey,
|
||||
arg.Name,
|
||||
arg.Token,
|
||||
arg.Claims,
|
||||
arg.CreatedBy,
|
||||
)
|
||||
var i Apikey
|
||||
err := row.Scan(
|
||||
&i.Pk,
|
||||
&i.Id,
|
||||
&i.Name,
|
||||
&i.Token,
|
||||
&i.Claims,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.LastUsed,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteApiKey = `-- name: DeleteApiKey :one
|
||||
delete from apikeys
|
||||
where id = $1
|
||||
returning
|
||||
pk, id, name, token, claims, created_by, created_at, last_used
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteApiKey(ctx context.Context, id uuid.UUID) (Apikey, error) {
|
||||
row := q.db.QueryRow(ctx, deleteApiKey, id)
|
||||
var i Apikey
|
||||
err := row.Scan(
|
||||
&i.Pk,
|
||||
&i.Id,
|
||||
&i.Name,
|
||||
&i.Token,
|
||||
&i.Claims,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.LastUsed,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getApiKey = `-- name: GetApiKey :one
|
||||
select
|
||||
pk, id, name, token, claims, created_by, created_at, last_used
|
||||
from
|
||||
apikeys
|
||||
where
|
||||
name = $1
|
||||
and token = $2
|
||||
`
|
||||
|
||||
type GetApiKeyParams struct {
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetApiKey(ctx context.Context, arg GetApiKeyParams) (Apikey, error) {
|
||||
row := q.db.QueryRow(ctx, getApiKey, arg.Name, arg.Token)
|
||||
var i Apikey
|
||||
err := row.Scan(
|
||||
&i.Pk,
|
||||
&i.Id,
|
||||
&i.Name,
|
||||
&i.Token,
|
||||
&i.Claims,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.LastUsed,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listApiKeys = `-- name: ListApiKeys :many
|
||||
select
|
||||
pk, id, name, token, claims, created_by, created_at, last_used
|
||||
from
|
||||
apikeys
|
||||
order by
|
||||
last_used
|
||||
`
|
||||
|
||||
func (q *Queries) ListApiKeys(ctx context.Context) ([]Apikey, error) {
|
||||
rows, err := q.db.Query(ctx, listApiKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Apikey
|
||||
for rows.Next() {
|
||||
var i Apikey
|
||||
if err := rows.Scan(
|
||||
&i.Pk,
|
||||
&i.Id,
|
||||
&i.Name,
|
||||
&i.Token,
|
||||
&i.Claims,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.LastUsed,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const touchApiKey = `-- name: TouchApiKey :exec
|
||||
update
|
||||
apikeys
|
||||
set
|
||||
last_used = now()::timestamptz
|
||||
where
|
||||
pk = $1
|
||||
`
|
||||
|
||||
func (q *Queries) TouchApiKey(ctx context.Context, pk int32) error {
|
||||
_, err := q.db.Exec(ctx, touchApiKey, pk)
|
||||
return err
|
||||
}
|
@ -11,6 +11,17 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Apikey struct {
|
||||
Pk int32 `json:"pk"`
|
||||
Id uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Claims jwt.MapClaims `json:"claims"`
|
||||
CreatedBy *int32 `json:"createdBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastUsed time.Time `json:"lastUsed"`
|
||||
}
|
||||
|
||||
type OidcHandle struct {
|
||||
UserPk int32 `json:"userPk"`
|
||||
Provider string `json:"provider"`
|
||||
|
@ -88,6 +88,7 @@ func (q *Queries) DeleteSession(ctx context.Context, arg DeleteSessionParams) (S
|
||||
|
||||
const getUserFromToken = `-- name: GetUserFromToken :one
|
||||
select
|
||||
s.pk,
|
||||
s.id,
|
||||
s.last_used,
|
||||
u.pk, u.id, u.username, u.email, u.password, u.claims, u.created_date, u.last_seen
|
||||
@ -100,6 +101,7 @@ limit 1
|
||||
`
|
||||
|
||||
type GetUserFromTokenRow struct {
|
||||
Pk int32 `json:"pk"`
|
||||
Id uuid.UUID `json:"id"`
|
||||
LastUsed time.Time `json:"lastUsed"`
|
||||
User User `json:"user"`
|
||||
@ -109,6 +111,7 @@ func (q *Queries) GetUserFromToken(ctx context.Context, token string) (GetUserFr
|
||||
row := q.db.QueryRow(ctx, getUserFromToken, token)
|
||||
var i GetUserFromTokenRow
|
||||
err := row.Scan(
|
||||
&i.Pk,
|
||||
&i.Id,
|
||||
&i.LastUsed,
|
||||
&i.User.Pk,
|
||||
@ -169,10 +172,10 @@ update
|
||||
set
|
||||
last_used = now()::timestamptz
|
||||
where
|
||||
id = $1
|
||||
pk = $1
|
||||
`
|
||||
|
||||
func (q *Queries) TouchSession(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, touchSession, id)
|
||||
func (q *Queries) TouchSession(ctx context.Context, pk int32) error {
|
||||
_, err := q.db.Exec(ctx, touchSession, pk)
|
||||
return err
|
||||
}
|
||||
|
@ -261,11 +261,11 @@ update
|
||||
set
|
||||
last_used = now()::timestamptz
|
||||
where
|
||||
id = $1
|
||||
pk = $1
|
||||
`
|
||||
|
||||
func (q *Queries) TouchUser(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, touchUser, id)
|
||||
func (q *Queries) TouchUser(ctx context.Context, pk int32) error {
|
||||
_, err := q.db.Exec(ctx, touchUser, pk)
|
||||
return err
|
||||
}
|
||||
|
||||
|
17
auth/jwt.go
17
auth/jwt.go
@ -19,7 +19,7 @@ type Jwt struct {
|
||||
}
|
||||
|
||||
// @Summary Get JWT
|
||||
// @Description Convert a session token to a short lived JWT.
|
||||
// @Description Convert a session token or an API key to a short lived JWT.
|
||||
// @Tags jwt
|
||||
// @Produce json
|
||||
// @Security Token
|
||||
@ -28,6 +28,17 @@ type Jwt struct {
|
||||
// @Header 200 {string} Authorization "Jwt (same value as the returned token)"
|
||||
// @Router /jwt [get]
|
||||
func (h *Handler) CreateJwt(c echo.Context) error {
|
||||
apikey := c.Request().Header.Get("X-Api-Key")
|
||||
if apikey != "" {
|
||||
token, err := h.createApiJwt(apikey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, Jwt{
|
||||
Token: &token,
|
||||
})
|
||||
}
|
||||
|
||||
auth := c.Request().Header.Get("Authorization")
|
||||
var jwt *string
|
||||
|
||||
@ -85,8 +96,8 @@ func (h *Handler) createJwt(token string) (string, error) {
|
||||
}
|
||||
|
||||
go func() {
|
||||
h.db.TouchSession(context.Background(), session.Id)
|
||||
h.db.TouchUser(context.Background(), session.User.Id)
|
||||
h.db.TouchSession(context.Background(), session.Pk)
|
||||
h.db.TouchUser(context.Background(), session.User.Pk)
|
||||
}()
|
||||
|
||||
claims := maps.Clone(session.User.Claims)
|
||||
|
42
auth/main.go
42
auth/main.go
@ -131,28 +131,38 @@ type Handler struct {
|
||||
|
||||
func (h *Handler) TokenToJwt(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
auth := c.Request().Header.Get("Authorization")
|
||||
var jwt *string
|
||||
|
||||
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
|
||||
jwt = h.createGuestJwt()
|
||||
} else {
|
||||
token := auth[len("Bearer "):]
|
||||
// this is only used to check if it is a session token or a jwt
|
||||
_, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
tkn, err := h.createJwt(token)
|
||||
apikey := c.Request().Header.Get("X-Api-Key")
|
||||
if apikey != "" {
|
||||
token, err := h.createApiJwt(apikey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jwt = &tkn
|
||||
jwt = &token
|
||||
} else {
|
||||
auth := c.Request().Header.Get("Authorization")
|
||||
|
||||
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
|
||||
jwt = h.createGuestJwt()
|
||||
} else {
|
||||
token := auth[len("Bearer "):]
|
||||
// this is only used to check if it is a session token or a jwt
|
||||
_, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
tkn, err := h.createJwt(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jwt = &tkn
|
||||
}
|
||||
}
|
||||
|
||||
if jwt != nil {
|
||||
c.Request().Header.Set("Authorization", *jwt)
|
||||
c.Request().Header.Set("Authorization", fmt.Sprintf("Bearer %s", *jwt))
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
@ -224,6 +234,10 @@ func main() {
|
||||
r.DELETE("/sessions", h.Logout)
|
||||
r.DELETE("/sessions/:id", h.Logout)
|
||||
|
||||
r.GET("/keys", h.ListApiKey)
|
||||
r.POST("/keys", h.CreateApiKey)
|
||||
r.DELETE("/keys/:id", h.DeleteApiKey)
|
||||
|
||||
g.GET("/jwt", h.CreateJwt)
|
||||
e.GET("/.well-known/jwks.json", h.GetJwks)
|
||||
e.GET("/.well-known/openid-configuration", h.GetOidcConfig)
|
||||
|
5
auth/sql/migrations/000003_apikeys.down.sql
Normal file
5
auth/sql/migrations/000003_apikeys.down.sql
Normal file
@ -0,0 +1,5 @@
|
||||
begin;
|
||||
|
||||
drop table apikeys;
|
||||
|
||||
commit;
|
15
auth/sql/migrations/000003_apikeys.up.sql
Normal file
15
auth/sql/migrations/000003_apikeys.up.sql
Normal file
@ -0,0 +1,15 @@
|
||||
begin;
|
||||
|
||||
create table apikeys(
|
||||
pk serial primary key,
|
||||
id uuid not null default gen_random_uuid(),
|
||||
name varchar(256) not null unique,
|
||||
token varchar(128) not null unique,
|
||||
claims jsonb not null,
|
||||
|
||||
created_by integer references users(pk) on delete cascade,
|
||||
created_at timestamptz not null default now()::timestamptz,
|
||||
last_used timestamptz not null default now()::timestamptz
|
||||
);
|
||||
|
||||
commit;
|
37
auth/sql/queries/apikeys.sql
Normal file
37
auth/sql/queries/apikeys.sql
Normal file
@ -0,0 +1,37 @@
|
||||
-- name: GetApiKey :one
|
||||
select
|
||||
*
|
||||
from
|
||||
apikeys
|
||||
where
|
||||
name = $1
|
||||
and token = $2;
|
||||
|
||||
-- name: TouchApiKey :exec
|
||||
update
|
||||
apikeys
|
||||
set
|
||||
last_used = now()::timestamptz
|
||||
where
|
||||
pk = $1;
|
||||
|
||||
-- name: ListApiKeys :many
|
||||
select
|
||||
*
|
||||
from
|
||||
apikeys
|
||||
order by
|
||||
last_used;
|
||||
|
||||
-- name: CreateApiKey :one
|
||||
insert into apikeys(name, token, claims, created_by)
|
||||
values ($1, $2, $3, $4)
|
||||
returning
|
||||
*;
|
||||
|
||||
-- name: DeleteApiKey :one
|
||||
delete from apikeys
|
||||
where id = $1
|
||||
returning
|
||||
*;
|
||||
|
@ -1,5 +1,6 @@
|
||||
-- name: GetUserFromToken :one
|
||||
select
|
||||
s.pk,
|
||||
s.id,
|
||||
s.last_used,
|
||||
sqlc.embed(u)
|
||||
@ -16,7 +17,7 @@ update
|
||||
set
|
||||
last_used = now()::timestamptz
|
||||
where
|
||||
id = $1;
|
||||
pk = $1;
|
||||
|
||||
-- name: GetUserSessions :many
|
||||
select
|
||||
|
@ -49,7 +49,7 @@ update
|
||||
set
|
||||
last_used = now()::timestamptz
|
||||
where
|
||||
id = $1;
|
||||
pk = $1;
|
||||
|
||||
-- name: CreateUser :one
|
||||
insert into users(username, email, password, claims)
|
||||
|
@ -35,5 +35,10 @@ sql:
|
||||
import: "github.com/golang-jwt/jwt/v5"
|
||||
package: "jwt"
|
||||
type: "MapClaims"
|
||||
- column: "apikeys.claims"
|
||||
go_type:
|
||||
import: "github.com/golang-jwt/jwt/v5"
|
||||
package: "jwt"
|
||||
type: "MapClaims"
|
||||
|
||||
|
||||
|
61
auth/tests/apikey.hurl
Normal file
61
auth/tests/apikey.hurl
Normal file
@ -0,0 +1,61 @@
|
||||
# perm check
|
||||
POST {{host}}/keys
|
||||
{
|
||||
"name": "dryflower",
|
||||
"claims": {
|
||||
"isAdmin": true,
|
||||
"permissions": ["core.read"]
|
||||
}
|
||||
}
|
||||
HTTP 401
|
||||
|
||||
POST {{host}}/keys
|
||||
# this is created from the gh workflow file's env var
|
||||
X-API-KEY: hurl-1234apikey
|
||||
{
|
||||
"name": "dryflower",
|
||||
"claims": {
|
||||
"isAdmin": true,
|
||||
"permissions": ["apikeys.read"]
|
||||
}
|
||||
}
|
||||
HTTP 201
|
||||
[Captures]
|
||||
id: jsonpath "$.id"
|
||||
token: jsonpath "$.token"
|
||||
|
||||
GET {{host}}/jwt
|
||||
X-API-KEY: {{token}}
|
||||
HTTP 200
|
||||
[Captures]
|
||||
jwt: jsonpath "$.token"
|
||||
|
||||
# Duplicates email
|
||||
POST {{host}}/keys
|
||||
X-API-KEY: hurl-1234apikey
|
||||
{
|
||||
"name": "dryflower",
|
||||
"claims": {
|
||||
"isAdmin": true,
|
||||
"permissions": ["core.read"]
|
||||
}
|
||||
}
|
||||
HTTP 409
|
||||
|
||||
# List
|
||||
GET {{host}}/keys
|
||||
Authorization: Bearer {{jwt}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.items[0].id" == {{id}}
|
||||
jsonpath "$.items[0].name" == "dryflower"
|
||||
jsonpath "$.items[0].claims.permissions" contains "apikeys.read"
|
||||
|
||||
|
||||
DELETE {{host}}/keys/{{id}}
|
||||
Authorization: Bearer {{jwt}}
|
||||
HTTP 403
|
||||
|
||||
DELETE {{host}}/keys/{{id}}
|
||||
X-API-KEY: hurl-1234apikey
|
||||
HTTP 200
|
@ -250,8 +250,8 @@ func (h *Handler) Register(c echo.Context) error {
|
||||
// @Security Jwt[users.delete]
|
||||
// @Param id path string false "User id of the user to delete" Format(uuid)
|
||||
// @Success 200 {object} User
|
||||
// @Failure 404 {object} KError "Invalid id format"
|
||||
// @Failure 404 {object} KError "Invalid user id"
|
||||
// @Failure 422 {object} KError "Invalid id format"
|
||||
// @Router /users/{id} [delete]
|
||||
func (h *Handler) DeleteUser(c echo.Context) error {
|
||||
err := CheckPermissions(c, []string{"users.delete"})
|
||||
@ -261,7 +261,7 @@ func (h *Handler) DeleteUser(c echo.Context) error {
|
||||
|
||||
uid, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(400, "Invalid id given: not an uuid")
|
||||
return echo.NewHTTPError(422, "Invalid id given: not an uuid")
|
||||
}
|
||||
|
||||
ret, err := h.db.DeleteUser(context.Background(), uid)
|
||||
|
@ -56,7 +56,12 @@ func GetCurrentSessionId(c echo.Context) (uuid.UUID, error) {
|
||||
|
||||
func CheckPermissions(c echo.Context, perms []string) error {
|
||||
token, ok := c.Get("user").(*jwt.Token)
|
||||
if !ok {
|
||||
if !ok{
|
||||
return echo.NewHTTPError(401, "Not logged in")
|
||||
}
|
||||
sub, err := token.Claims.GetSubject()
|
||||
// ignore guests
|
||||
if err != nil || sub == "00000000-0000-0000-0000-000000000000" {
|
||||
return echo.NewHTTPError(401, "Not logged in")
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
@ -66,10 +71,8 @@ func CheckPermissions(c echo.Context, perms []string) error {
|
||||
|
||||
permissions_claims, ok := claims["permissions"]
|
||||
if !ok {
|
||||
return echo.NewHTTPError(403, fmt.Sprintf("Missing permissions: %s.", ", "))
|
||||
return echo.NewHTTPError(403, fmt.Sprintf("No permissions on this account. Needs permissions: %s.", strings.Join(perms, ", ")))
|
||||
}
|
||||
fmt.Printf("%v\n", permissions_claims)
|
||||
fmt.Printf("%t\n", permissions_claims)
|
||||
permissions_int, ok := permissions_claims.([]any)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(403, "Invalid permission claim.")
|
||||
|
@ -11,7 +11,6 @@ logger = getLogger(__name__)
|
||||
|
||||
|
||||
def get_ignore_pattern():
|
||||
"""Compile ignore pattern from environment variable."""
|
||||
try:
|
||||
pattern = os.environ.get("LIBRARY_IGNORE_PATTERN")
|
||||
return re.compile(pattern) if pattern else None
|
||||
|
Loading…
x
Reference in New Issue
Block a user