Add edit user/settings route

This commit is contained in:
Zoe Roux 2025-04-05 15:26:58 +02:00
parent a903d88a66
commit dbe8e319c8
No known key found for this signature in database
11 changed files with 365 additions and 16 deletions

View File

@ -93,3 +93,10 @@ RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672 RABBITMQ_PORT=5672
RABBITMQ_DEFAULT_USER=kyoo RABBITMQ_DEFAULT_USER=kyoo
RABBITMQ_DEFAULT_PASS=aohohunuhouhuhhoahothonseuhaoensuthoaentsuhha RABBITMQ_DEFAULT_PASS=aohohunuhouhuhhoahothonseuhaoensuthoaentsuhha
# v5 stuff, does absolutely nothing on master (aka: you can delete this)
EXTRA_CLAIMS='{"permissions": [], "verified": false}'
FIRST_USER_CLAIMS='{"permissions": ["user.read", "users.write", "users.delete"], "verified": true}'
GUEST_CLAIMS='{"permissions": []}'
PROTECTED_CLAIMS="permissions,verified"

View File

@ -57,8 +57,3 @@ jobs:
working-directory: ./auth working-directory: ./auth
run: cat logs run: cat logs
- uses: actions/upload-artifact@v4
with:
name: results
path: auth/out

View File

@ -14,6 +14,11 @@ EXTRA_CLAIMS='{}'
FIRST_USER_CLAIMS='{}' FIRST_USER_CLAIMS='{}'
# If this is not empty, calls to `/jwt` without an `Authorization` header will still create a jwt (with `null` in `sub`) # If this is not empty, calls to `/jwt` without an `Authorization` header will still create a jwt (with `null` in `sub`)
GUEST_CLAIMS="" GUEST_CLAIMS=""
# Comma separated list of claims that users without the `user.write` permissions should NOT be able to edit
# (if you don't specify this an user could make themself administrator for example)
# PS: `permissions` is always a protected claim since keibi uses it for user.read/user.write
PROTECTED_CLAIMS="permissions"
# The url you can use to reach your kyoo instance. This is used during oidc to redirect users to your instance. # 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 PUBLIC_URL=http://localhost:8901

View File

@ -8,6 +8,7 @@ import (
"encoding/pem" "encoding/pem"
"maps" "maps"
"os" "os"
"strings"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@ -22,12 +23,14 @@ type Configuration struct {
DefaultClaims jwt.MapClaims DefaultClaims jwt.MapClaims
FirstUserClaims jwt.MapClaims FirstUserClaims jwt.MapClaims
GuestClaims jwt.MapClaims GuestClaims jwt.MapClaims
ProtectedClaims []string
ExpirationDelay time.Duration ExpirationDelay time.Duration
} }
var DefaultConfig = Configuration{ var DefaultConfig = Configuration{
DefaultClaims: make(jwt.MapClaims), DefaultClaims: make(jwt.MapClaims),
FirstUserClaims: make(jwt.MapClaims), FirstUserClaims: make(jwt.MapClaims),
ProtectedClaims: []string{"permissions"},
ExpirationDelay: 30 * 24 * time.Hour, ExpirationDelay: 30 * 24 * time.Hour,
} }
@ -64,6 +67,9 @@ func LoadConfiguration(db *dbc.Queries) (*Configuration, error) {
} }
} }
protected := strings.Split(os.Getenv("PROTECTED_CLAIMS"), ",")
ret.ProtectedClaims = append(ret.ProtectedClaims, protected...)
rsa_pk_path := os.Getenv("RSA_PRIVATE_KEY_PATH") rsa_pk_path := os.Getenv("RSA_PRIVATE_KEY_PATH")
if rsa_pk_path != "" { if rsa_pk_path != "" {
privateKeyData, err := os.ReadFile(rsa_pk_path) privateKeyData, err := os.ReadFile(rsa_pk_path)

View File

@ -265,10 +265,10 @@ const updateUser = `-- name: UpdateUser :one
update update
users users
set set
username = $2, username = coalesce($2, username),
email = $3, email = coalesce($3, email),
password = $4, password = coalesce($4, password),
claims = $5 claims = coalesce($5, claims)
where where
id = $1 id = $1
returning returning
@ -277,8 +277,8 @@ returning
type UpdateUserParams struct { type UpdateUserParams struct {
Id uuid.UUID `json:"id"` Id uuid.UUID `json:"id"`
Username string `json:"username"` Username *string `json:"username"`
Email string `json:"email"` Email *string `json:"email"`
Password *string `json:"password"` Password *string `json:"password"`
Claims jwt.MapClaims `json:"claims"` Claims jwt.MapClaims `json:"claims"`
} }

View File

@ -377,6 +377,48 @@ const docTemplate = `{
} }
} }
} }
},
"patch": {
"security": [
{
"Jwt": []
}
],
"description": "Edit your account's info",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"users"
],
"summary": "Edit self",
"parameters": [
{
"description": "Edited user info",
"name": "user",
"in": "body",
"schema": {
"$ref": "#/definitions/main.EditUserDto"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/main.User"
}
},
"403": {
"description": "You can't edit a protected claim",
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
} }
}, },
"/users/{id}": { "/users/{id}": {
@ -469,10 +511,83 @@ const docTemplate = `{
} }
} }
} }
},
"patch": {
"security": [
{
"Jwt": [
"users.write"
]
}
],
"description": "Edit an account info or permissions",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"users"
],
"summary": "Edit user",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "User id of the user to delete",
"name": "id",
"in": "path"
},
{
"description": "Edited user info",
"name": "user",
"in": "body",
"schema": {
"$ref": "#/definitions/main.EditUserDto"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/main.User"
}
},
"403": {
"description": "You don't have permissions to edit another account",
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
} }
} }
}, },
"definitions": { "definitions": {
"main.EditUserDto": {
"type": "object",
"properties": {
"claims": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"example": {
"preferOriginal": " true"
}
},
"email": {
"type": "string",
"example": "kyoo@zoriya.dev"
},
"username": {
"type": "string",
"example": "zoriya"
}
}
},
"main.JwkSet": { "main.JwkSet": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -371,6 +371,48 @@
} }
} }
} }
},
"patch": {
"security": [
{
"Jwt": []
}
],
"description": "Edit your account's info",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"users"
],
"summary": "Edit self",
"parameters": [
{
"description": "Edited user info",
"name": "user",
"in": "body",
"schema": {
"$ref": "#/definitions/main.EditUserDto"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/main.User"
}
},
"403": {
"description": "You can't edit a protected claim",
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
} }
}, },
"/users/{id}": { "/users/{id}": {
@ -463,10 +505,83 @@
} }
} }
} }
},
"patch": {
"security": [
{
"Jwt": [
"users.write"
]
}
],
"description": "Edit an account info or permissions",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"users"
],
"summary": "Edit user",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "User id of the user to delete",
"name": "id",
"in": "path"
},
{
"description": "Edited user info",
"name": "user",
"in": "body",
"schema": {
"$ref": "#/definitions/main.EditUserDto"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/main.User"
}
},
"403": {
"description": "You don't have permissions to edit another account",
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
} }
} }
}, },
"definitions": { "definitions": {
"main.EditUserDto": {
"type": "object",
"properties": {
"claims": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"example": {
"preferOriginal": " true"
}
},
"email": {
"type": "string",
"example": "kyoo@zoriya.dev"
},
"username": {
"type": "string",
"example": "zoriya"
}
}
},
"main.JwkSet": { "main.JwkSet": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -215,6 +215,8 @@ func main() {
r.GET("/users/me", h.GetMe) r.GET("/users/me", h.GetMe)
r.DELETE("/users/:id", h.DeleteUser) r.DELETE("/users/:id", h.DeleteUser)
r.DELETE("/users/me", h.DeleteSelf) r.DELETE("/users/me", h.DeleteSelf)
r.PATCH("/users/:id", h.EditUser)
r.PATCH("/users/me", h.EditSelf)
g.POST("/users", h.Register) g.POST("/users", h.Register)
g.POST("/sessions", h.Login) g.POST("/sessions", h.Login)

View File

@ -125,7 +125,7 @@ func (h *Handler) createSession(c echo.Context, user *User) error {
if err != nil { if err != nil {
return err return err
} }
return c.JSON(201, session) return c.JSON(201, MapSessionToken(&session))
} }
// @Summary Logout // @Summary Logout

View File

@ -67,10 +67,10 @@ returning
update update
users users
set set
username = $2, username = coalesce(sqlc.narg(username), username),
email = $3, email = coalesce(sqlc.narg(email), email),
password = $4, password = coalesce(sqlc.narg(password), password),
claims = $5 claims = coalesce(sqlc.narg(claims), claims)
where where
id = $1 id = $1
returning returning

View File

@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"time" "time"
@ -51,6 +52,12 @@ type RegisterDto struct {
Password string `json:"password" validate:"required" example:"password1234"` Password string `json:"password" validate:"required" example:"password1234"`
} }
type EditUserDto struct {
Username *string `json:"username,omitempty" validate:"excludes=@" example:"zoriya"`
Email *string `json:"email,omitempty" validate:"email" example:"kyoo@zoriya.dev"`
Claims jwt.MapClaims `json:"claims,omitempty" example:"preferOriginal: true"`
}
func MapDbUser(user *dbc.User) User { func MapDbUser(user *dbc.User) User {
return User{ return User{
Pk: user.Pk, Pk: user.Pk,
@ -235,6 +242,11 @@ func (h *Handler) Register(c echo.Context) error {
// @Failure 404 {object} KError "Invalid user id" // @Failure 404 {object} KError "Invalid user id"
// @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{"user.delete"})
if err != nil {
return err
}
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(400, "Invalid id given: not an uuid")
@ -271,3 +283,95 @@ func (h *Handler) DeleteSelf(c echo.Context) error {
} }
return c.JSON(200, MapDbUser(&ret)) return c.JSON(200, MapDbUser(&ret))
} }
// @Summary Edit self
// @Description Edit your account's info
// @Tags users
// @Accept json
// @Produce json
// @Security Jwt
// @Param user body EditUserDto false "Edited user info"
// @Success 200 {object} User
// @Success 403 {object} KError "You can't edit a protected claim"
// @Router /users/me [patch]
func (h *Handler) EditSelf(c echo.Context) error {
var req EditUserDto
err := c.Bind(&req)
if err != nil {
return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error())
}
if err = c.Validate(&req); err != nil {
return err
}
for _, key := range h.config.ProtectedClaims {
if _, contains := req.Claims[key]; contains {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("Can't edit protected claim: '%s'.", key))
}
}
uid, err := GetCurrentUserId(c)
if err != nil {
return err
}
ret, err := h.db.UpdateUser(context.Background(), dbc.UpdateUserParams{
Id: uid,
Username: req.Username,
Email: req.Email,
Claims: req.Claims,
})
if err == pgx.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound, "Invalid token, user not found.")
} else if err != nil {
return err
}
return c.JSON(200, MapDbUser(&ret))
}
// @Summary Edit user
// @Description Edit an account info or permissions
// @Tags users
// @Accept json
// @Produce json
// @Security Jwt[users.write]
// @Param id path string false "User id of the user to edit" Format(uuid)
// @Param user body EditUserDto false "Edited user info"
// @Success 200 {object} User
// @Success 403 {object} KError "You don't have permissions to edit another account"
// @Router /users/{id} [patch]
func (h *Handler) EditUser(c echo.Context) error {
err := CheckPermissions(c, []string{"user.write"})
if err != nil {
return err
}
uid, err := uuid.Parse(c.Param("id"))
if err != nil {
return echo.NewHTTPError(400, "Invalid id given: not an uuid")
}
var req EditUserDto
err = c.Bind(&req)
if err != nil {
return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error())
}
if err = c.Validate(&req); err != nil {
return err
}
ret, err := h.db.UpdateUser(context.Background(), dbc.UpdateUserParams{
Id: uid,
Username: req.Username,
Email: req.Email,
Claims: req.Claims,
})
if err == pgx.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound, "Invalid user id, user not found")
} else if err != nil {
return err
}
return c.JSON(200, MapDbUser(&ret))
}