Add apikeys routes

This commit is contained in:
Zoe Roux 2025-04-20 19:09:13 +02:00
parent 822a7029ef
commit 85186a74c8
No known key found for this signature in database
9 changed files with 240 additions and 7 deletions

View File

@ -1,26 +1,50 @@
package main package main
import ( import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"net/http" "net/http"
"time" "time"
"github.com/golang-jwt/jwt/v5" "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/labstack/echo/v4"
"github.com/zoriya/kyoo/keibi/dbc"
) )
type ApiKey struct { type ApiKey struct {
Name string `json:"name" example:"my-app"` Name string `json:"name" example:"myapp"`
Token string `json:"token" example:"lyHzTYm9yi+pkEv3m2tamAeeK7Dj7N3QRP7xv7dPU5q9MAe8tU4ySwYczE0RaMr4fijsA=="`
CreatedAt time.Time `json:"createAt" example:"2025-03-29T18:20:05.267Z"` CreatedAt time.Time `json:"createAt" example:"2025-03-29T18:20:05.267Z"`
LastUsed time.Time `json:"lastUsed" 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"` Claims jwt.MapClaims `json:"claims" example:"isAdmin: true"`
} }
type ApiKeyWToken struct {
ApiKey
Token string `json:"token" example:"myapp-lyHzTYm9yi+pkEv3m2tamAeeK7Dj7N3QRP7xv7dPU5q9MAe8tU4ySwYczE0RaMr4fijsA=="`
}
type ApiKeyDto struct { type ApiKeyDto struct {
Name string `json:"name" example:"my-app" validate:"alpha"` Name string `json:"name" example:"my-app" validate:"alpha"`
Claims jwt.MapClaims `json:"claims" example:"isAdmin: true"` Claims jwt.MapClaims `json:"claims" example:"isAdmin: true"`
} }
func MapDbKey(key *dbc.Apikey) ApiKeyWToken {
return ApiKeyWToken{
ApiKey: ApiKey{
Name: key.Name,
Claims: key.Claims,
CreatedAt: key.CreatedAt,
LastUsed: key.LastUsed,
},
Token: key.Token,
}
}
// @Summary Create API key // @Summary Create API key
// @Description Create a new API key // @Description Create a new API key
// @Tags apikeys // @Tags apikeys
@ -28,10 +52,10 @@ type ApiKeyDto struct {
// @Produce json // @Produce json
// @Security Jwt[apikeys.write] // @Security Jwt[apikeys.write]
// @Param key body ApiKeyDto false "Api key info" // @Param key body ApiKeyDto false "Api key info"
// @Success 201 {object} ApiKey // @Success 201 {object} ApiKeyWToken
// @Failure 409 {object} KError "Duplicated api key" // @Failure 409 {object} KError "Duplicated api key"
// @Failure 422 {object} KError "Invalid create body" // @Failure 422 {object} KError "Invalid create body"
// @Router /users [get] // @Router /keys [post]
func (h *Handler) CreateApiKey(c echo.Context) error { func (h *Handler) CreateApiKey(c echo.Context) error {
var req ApiKeyDto var req ApiKeyDto
err := c.Bind(&req) err := c.Bind(&req)
@ -41,4 +65,70 @@ func (h *Handler) CreateApiKey(c echo.Context) error {
if err = c.Validate(&req); err != nil { if err = c.Validate(&req); err != nil {
return err return err
} }
id := make([]byte, 64)
_, err = rand.Read(id)
if err != nil {
return err
}
dbkey, err := h.db.CreateApiKey(context.Background(), dbc.CreateApiKeyParams{
Name: req.Name,
Token: fmt.Sprintf("%s-%s", req.Name, base64.RawURLEncoding.EncodeToString(id)),
Claims: req.Claims,
})
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 {
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 {
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)
}
return c.JSON(200, Page[ApiKey]{
Items: ret,
This: c.Request().URL.String(),
})
} }

103
auth/dbc/apikeys.sql.go Normal file
View File

@ -0,0 +1,103 @@
// 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)
values ($1, $2, $3)
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"`
}
func (q *Queries) CreateApiKey(ctx context.Context, arg CreateApiKeyParams) (Apikey, error) {
row := q.db.QueryRow(ctx, createApiKey, arg.Name, arg.Token, arg.Claims)
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 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
}

View File

@ -11,6 +11,17 @@ import (
"github.com/google/uuid" "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 { type OidcHandle struct {
UserPk int32 `json:"userPk"` UserPk int32 `json:"userPk"`
Provider string `json:"provider"` Provider string `json:"provider"`

View File

@ -224,6 +224,10 @@ func main() {
r.DELETE("/sessions", h.Logout) r.DELETE("/sessions", h.Logout)
r.DELETE("/sessions/:id", h.Logout) r.DELETE("/sessions/:id", h.Logout)
r.GET("/keys", h.ListApiKey)
r.POST("/keys", h.CreateApiKey)
r.DELETE("/keys", h.DeleteApiKey)
g.GET("/jwt", h.CreateJwt) g.GET("/jwt", h.CreateJwt)
e.GET("/.well-known/jwks.json", h.GetJwks) e.GET("/.well-known/jwks.json", h.GetJwks)
e.GET("/.well-known/openid-configuration", h.GetOidcConfig) e.GET("/.well-known/openid-configuration", h.GetOidcConfig)

View File

@ -7,6 +7,7 @@ create table apikeys(
token varchar(128) not null unique, token varchar(128) not null unique,
claims jsonb not null, claims jsonb not null,
created_by integer not null references users(pk) on delete cascade,
created_at timestamptz not null default now()::timestamptz, created_at timestamptz not null default now()::timestamptz,
last_used timestamptz not null default now()::temistamptz last_used timestamptz not null default now()::temistamptz
); );

View File

@ -0,0 +1,20 @@
-- name: ListApiKeys :many
select
*
from
apikeys
order by
last_used;
-- name: CreateApiKey :one
insert into apikeys(name, token, claims)
values ($1, $2, $3)
returning
*;
-- name: DeleteApiKey :one
delete from apikeys
where id = $1
returning
*;

View File

@ -35,5 +35,10 @@ sql:
import: "github.com/golang-jwt/jwt/v5" import: "github.com/golang-jwt/jwt/v5"
package: "jwt" package: "jwt"
type: "MapClaims" type: "MapClaims"
- column: "apikeys.claims"
go_type:
import: "github.com/golang-jwt/jwt/v5"
package: "jwt"
type: "MapClaims"

View File

@ -250,8 +250,8 @@ func (h *Handler) Register(c echo.Context) error {
// @Security Jwt[users.delete] // @Security Jwt[users.delete]
// @Param id path string false "User id of the user to delete" Format(uuid) // @Param id path string false "User id of the user to delete" Format(uuid)
// @Success 200 {object} User // @Success 200 {object} User
// @Failure 404 {object} KError "Invalid id format"
// @Failure 404 {object} KError "Invalid user id" // @Failure 404 {object} KError "Invalid user id"
// @Failure 422 {object} KError "Invalid id format"
// @Router /users/{id} [delete] // @Router /users/{id} [delete]
func (h *Handler) DeleteUser(c echo.Context) error { func (h *Handler) DeleteUser(c echo.Context) error {
err := CheckPermissions(c, []string{"users.delete"}) 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")) uid, err := uuid.Parse(c.Param("id"))
if err != nil { 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) ret, err := h.db.DeleteUser(context.Background(), uid)

View File

@ -11,7 +11,6 @@ logger = getLogger(__name__)
def get_ignore_pattern(): def get_ignore_pattern():
"""Compile ignore pattern from environment variable."""
try: try:
pattern = os.environ.get("LIBRARY_IGNORE_PATTERN") pattern = os.environ.get("LIBRARY_IGNORE_PATTERN")
return re.compile(pattern) if pattern else None return re.compile(pattern) if pattern else None