diff --git a/.env.example b/.env.example index f81f9357..7ca66f24 100644 --- a/.env.example +++ b/.env.example @@ -93,3 +93,10 @@ RABBITMQ_HOST=rabbitmq RABBITMQ_PORT=5672 RABBITMQ_DEFAULT_USER=kyoo 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" diff --git a/.github/workflows/auth-hurl.yml b/.github/workflows/auth-hurl.yml index d5f47917..6ecbed1c 100644 --- a/.github/workflows/auth-hurl.yml +++ b/.github/workflows/auth-hurl.yml @@ -57,8 +57,3 @@ jobs: working-directory: ./auth run: cat logs - - uses: actions/upload-artifact@v4 - with: - name: results - path: auth/out - diff --git a/auth/.env.example b/auth/.env.example index 2ce80788..1e69fdee 100644 --- a/auth/.env.example +++ b/auth/.env.example @@ -14,6 +14,11 @@ EXTRA_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`) 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. PUBLIC_URL=http://localhost:8901 diff --git a/auth/config.go b/auth/config.go index ed3cc061..cc7ac656 100644 --- a/auth/config.go +++ b/auth/config.go @@ -8,6 +8,7 @@ import ( "encoding/pem" "maps" "os" + "strings" "time" "github.com/golang-jwt/jwt/v5" @@ -22,12 +23,14 @@ type Configuration struct { DefaultClaims jwt.MapClaims FirstUserClaims jwt.MapClaims GuestClaims jwt.MapClaims + ProtectedClaims []string ExpirationDelay time.Duration } var DefaultConfig = Configuration{ DefaultClaims: make(jwt.MapClaims), FirstUserClaims: make(jwt.MapClaims), + ProtectedClaims: []string{"permissions"}, 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") if rsa_pk_path != "" { privateKeyData, err := os.ReadFile(rsa_pk_path) diff --git a/auth/dbc/users.sql.go b/auth/dbc/users.sql.go index 22035cee..17e2e903 100644 --- a/auth/dbc/users.sql.go +++ b/auth/dbc/users.sql.go @@ -265,10 +265,10 @@ const updateUser = `-- name: UpdateUser :one update users set - username = $2, - email = $3, - password = $4, - claims = $5 + username = coalesce($2, username), + email = coalesce($3, email), + password = coalesce($4, password), + claims = coalesce($5, claims) where id = $1 returning @@ -277,8 +277,8 @@ returning type UpdateUserParams struct { Id uuid.UUID `json:"id"` - Username string `json:"username"` - Email string `json:"email"` + Username *string `json:"username"` + Email *string `json:"email"` Password *string `json:"password"` Claims jwt.MapClaims `json:"claims"` } diff --git a/auth/docs/docs.go b/auth/docs/docs.go index 124597f8..a7f7d070 100644 --- a/auth/docs/docs.go +++ b/auth/docs/docs.go @@ -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}": { @@ -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": { + "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": { "type": "object", "properties": { diff --git a/auth/docs/swagger.json b/auth/docs/swagger.json index 2215fcf0..e3d338e2 100644 --- a/auth/docs/swagger.json +++ b/auth/docs/swagger.json @@ -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}": { @@ -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": { + "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": { "type": "object", "properties": { diff --git a/auth/main.go b/auth/main.go index 05d05d26..13a0ca48 100644 --- a/auth/main.go +++ b/auth/main.go @@ -215,6 +215,8 @@ func main() { r.GET("/users/me", h.GetMe) r.DELETE("/users/:id", h.DeleteUser) 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("/sessions", h.Login) diff --git a/auth/sessions.go b/auth/sessions.go index 86a05a93..b274e6fe 100644 --- a/auth/sessions.go +++ b/auth/sessions.go @@ -125,7 +125,7 @@ func (h *Handler) createSession(c echo.Context, user *User) error { if err != nil { return err } - return c.JSON(201, session) + return c.JSON(201, MapSessionToken(&session)) } // @Summary Logout diff --git a/auth/sql/queries/users.sql b/auth/sql/queries/users.sql index 6881c861..1f1711dd 100644 --- a/auth/sql/queries/users.sql +++ b/auth/sql/queries/users.sql @@ -67,10 +67,10 @@ returning update users set - username = $2, - email = $3, - password = $4, - claims = $5 + username = coalesce(sqlc.narg(username), username), + email = coalesce(sqlc.narg(email), email), + password = coalesce(sqlc.narg(password), password), + claims = coalesce(sqlc.narg(claims), claims) where id = $1 returning diff --git a/auth/users.go b/auth/users.go index 53589652..542d6ea0 100644 --- a/auth/users.go +++ b/auth/users.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "net/http" "time" @@ -51,6 +52,12 @@ type RegisterDto struct { 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 { return User{ Pk: user.Pk, @@ -235,6 +242,11 @@ func (h *Handler) Register(c echo.Context) error { // @Failure 404 {object} KError "Invalid user id" // @Router /users/{id} [delete] 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")) if err != nil { 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)) } + +// @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)) +}