diff --git a/internal/app/api/core/assets/doc/v1_swagger.json b/internal/app/api/core/assets/doc/v1_swagger.json index d153b6a..e7a7776 100644 --- a/internal/app/api/core/assets/doc/v1_swagger.json +++ b/internal/app/api/core/assets/doc/v1_swagger.json @@ -19,7 +19,7 @@ "get": { "security": [ { - "AdminBasicAuth": [] + "BasicAuth": [] } ], "produces": [ @@ -53,7 +53,7 @@ "get": { "security": [ { - "UserBasicAuth": [] + "BasicAuth": [] } ], "description": "Normal users can only access their own record. Admins can access all records.", @@ -183,10 +183,7 @@ } }, "securityDefinitions": { - "AdminBasicAuth": { - "type": "basic" - }, - "UserBasicAuth": { + "BasicAuth": { "type": "basic" } } diff --git a/internal/app/api/core/assets/doc/v1_swagger.yaml b/internal/app/api/core/assets/doc/v1_swagger.yaml index 46893da..26c2a33 100644 --- a/internal/app/api/core/assets/doc/v1_swagger.yaml +++ b/internal/app/api/core/assets/doc/v1_swagger.yaml @@ -93,7 +93,7 @@ paths: schema: $ref: '#/definitions/models.Error' security: - - AdminBasicAuth: [] + - BasicAuth: [] summary: Get all user records. tags: - Users @@ -122,13 +122,11 @@ paths: schema: $ref: '#/definitions/models.Error' security: - - UserBasicAuth: [] + - BasicAuth: [] summary: Get a specific user record by its internal identifier. tags: - Users securityDefinitions: - AdminBasicAuth: - type: basic - UserBasicAuth: + BasicAuth: type: basic swagger: "2.0" diff --git a/internal/app/api/core/assets/tpl/rapidoc.gohtml b/internal/app/api/core/assets/tpl/rapidoc.gohtml index cdff0cf..40985ff 100644 --- a/internal/app/api/core/assets/tpl/rapidoc.gohtml +++ b/internal/app/api/core/assets/tpl/rapidoc.gohtml @@ -9,7 +9,7 @@ spec-url="{{ $.ApiSpecUrl }}" theme="dark" allow-server-selection="false" - allow-authentication="false" + allow-authentication="true" load-fonts="false" schema-expand-level="1" allow-spec-url-load="false" diff --git a/internal/app/api/v1/backend/user_service.go b/internal/app/api/v1/backend/user_service.go index 120bdba..b077daa 100644 --- a/internal/app/api/v1/backend/user_service.go +++ b/internal/app/api/v1/backend/user_service.go @@ -7,6 +7,7 @@ import ( "math" "sync" + "github.com/h44z/wg-portal/internal/app/users" "github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/domain" ) @@ -71,17 +72,50 @@ func (s UserService) GetUsers(ctx context.Context) ([]domain.User, error) { } func (s UserService) GetUserById(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) { + if err := domain.ValidateUserAccessRights(ctx, id); err != nil { + return nil, errors.Join(err, domain.ErrNoPermission) + } + user, err := s.users.GetUser(ctx, id) if err != nil { return nil, fmt.Errorf("unable to load user %s: %w", id, err) } - if err := domain.ValidateUserAccessRights(ctx, user.Identifier); err != nil { - return nil, errors.Join(err, domain.ErrNoPermission) - } - peers, _ := s.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case user.LinkedPeerCount = len(peers) return user, nil } + +func (s UserService) CreateUser(ctx context.Context, user *domain.User) (*domain.User, error) { + if err := domain.ValidateAdminAccessRights(ctx); err != nil { + return nil, err + } + + existingUser, err := s.users.GetUser(ctx, user.Identifier) + if err != nil && !errors.Is(err, domain.ErrNotFound) { + return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err) + } + if existingUser != nil { + return nil, errors.Join(fmt.Errorf("user %s already exists", user.Identifier), domain.ErrDuplicateEntry) + } + + if err := users.ValidateCreation(ctx, user); err != nil { + return nil, errors.Join(fmt.Errorf("creation not allowed: %w", err), domain.ErrInvalidData) + } + + err = user.HashPassword() + if err != nil { + return nil, err + } + + err = s.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) { + user.CopyCalculatedAttributes(u) + return user, nil + }) + if err != nil { + return nil, fmt.Errorf("creation failure: %w", err) + } + + return user, nil +} diff --git a/internal/app/api/v1/handlers/base.go b/internal/app/api/v1/handlers/base.go index 043b4ab..631ea6f 100644 --- a/internal/app/api/v1/handlers/base.go +++ b/internal/app/api/v1/handlers/base.go @@ -30,15 +30,7 @@ type Handler interface { // @contact.name WireGuard Portal Project // @contact.url https://github.com/h44z/wg-portal -// @securityDefinitions.basic AdminBasicAuth -// @in header -// @name Authorization -// @scope.admin Admin access required - -// @securityDefinitions.basic UserBasicAuth -// @in header -// @name Authorization -// @scope.user User access required +// @securityDefinitions.basic BasicAuth // @BasePath /api/v1 // @query.collection.format multi @@ -74,6 +66,10 @@ func ParseServiceError(err error) (int, models.Error) { code = http.StatusNotFound case errors.Is(err, domain.ErrNoPermission): code = http.StatusForbidden + case errors.Is(err, domain.ErrDuplicateEntry): + code = http.StatusConflict + case errors.Is(err, domain.ErrInvalidData): + code = http.StatusBadRequest } return code, models.Error{ diff --git a/internal/app/api/v1/handlers/endpoint_users.go b/internal/app/api/v1/handlers/endpoint_users.go index 716c624..252c54b 100644 --- a/internal/app/api/v1/handlers/endpoint_users.go +++ b/internal/app/api/v1/handlers/endpoint_users.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/h44z/wg-portal/internal/app/api/v0/model" "github.com/h44z/wg-portal/internal/app/api/v1/models" "github.com/h44z/wg-portal/internal/domain" ) @@ -12,6 +13,7 @@ import ( type UserService interface { GetUsers(ctx context.Context) ([]domain.User, error) GetUserById(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) + CreateUser(ctx context.Context, user *domain.User) (*domain.User, error) } type UserEndpoint struct { @@ -44,7 +46,7 @@ func (e UserEndpoint) RegisterRoutes(g *gin.RouterGroup, authenticator *authenti // @Success 200 {object} []models.User // @Failure 500 {object} models.Error // @Router /user/all [get] -// @Security AdminBasicAuth +// @Security BasicAuth func (e UserEndpoint) handleAllGet() gin.HandlerFunc { return func(c *gin.Context) { ctx := domain.SetUserInfoFromGin(c) @@ -65,6 +67,7 @@ func (e UserEndpoint) handleAllGet() gin.HandlerFunc { // @Tags Users // @Summary Get a specific user record by its internal identifier. // @Description Normal users can only access their own record. Admins can access all records. +// @Param id path string true "The user identifier." // @Produce json // @Success 200 {object} models.User // @Failure 403 {object} models.Error @@ -72,7 +75,7 @@ func (e UserEndpoint) handleAllGet() gin.HandlerFunc { // @Failure 404 {object} models.Error // @Failure 500 {object} models.Error // @Router /user/id/{id} [get] -// @Security UserBasicAuth +// @Security BasicAuth func (e UserEndpoint) handleByIdGet() gin.HandlerFunc { return func(c *gin.Context) { ctx := domain.SetUserInfoFromGin(c) @@ -89,6 +92,123 @@ func (e UserEndpoint) handleByIdGet() gin.HandlerFunc { return } - c.JSON(http.StatusOK, models.NewUser(user)) + c.JSON(http.StatusOK, models.NewUser(user, true)) + } +} + +// handleCreatePost returns a gorm handler function. +// +// @ID users_handleCreatePost +// @Tags Users +// @Summary Create a new user record. +// @Description Only admins can create new records. +// @Param request body models.User true "The user data." +// @Produce json +// @Success 200 {object} models.User +// @Failure 400 {object} models.Error +// @Failure 403 {object} models.Error +// @Failure 409 {object} models.Error +// @Failure 500 {object} models.Error +// @Router /user/new [post] +func (e UserEndpoint) handleCreatePost() gin.HandlerFunc { + return func(c *gin.Context) { + ctx := domain.SetUserInfoFromGin(c) + + var user models.User + err := c.BindJSON(&user) + if err != nil { + c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + newUser, err := e.users.CreateUser(ctx, models.NewDomainUser(&user)) + if err != nil { + c.JSON(ParseServiceError(err)) + return + } + + c.JSON(http.StatusOK, models.NewUser(newUser, true)) + } +} + +// handleUpdatePut returns a gorm handler function. +// +// @ID users_handleUpdatePut +// @Tags Users +// @Summary Update a user record. +// @Description Only admins can update existing records. +// @Param id path string true "The user identifier" +// @Param request body models.User true "The user data" +// @Produce json +// @Success 200 {object} models.User +// @Failure 400 {object} models.Error +// @Failure 403 {object} models.Error +// @Failure 404 {object} models.Error +// @Failure 500 {object} models.Error +// @Router /user/{id} [put] +func (e UserEndpoint) handleUpdatePut() gin.HandlerFunc { + return func(c *gin.Context) { + // TODO: implement + ctx := domain.SetUserInfoFromGin(c) + + id := Base64UrlDecode(c.Param("id")) + if id == "" { + c.JSON(http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing user id"}) + return + } + + var user model.User + err := c.BindJSON(&user) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + if id != user.Identifier { + c.JSON(http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "user id mismatch"}) + return + } + + updateUser, err := e.app.UpdateUser(ctx, model.NewDomainUser(&user)) + if err != nil { + c.JSON(http.StatusInternalServerError, + model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + return + } + + c.JSON(http.StatusOK, model.NewUser(updateUser, false)) + } +} + +// handleDelete returns a gorm handler function. +// +// @ID users_handleDelete +// @Tags Users +// @Summary Delete the user record. +// @Produce json +// @Param id path string true "The user identifier" +// @Success 204 "No content if deletion was successful" +// @Failure 400 {object} model.Error +// @Failure 500 {object} model.Error +// @Router /user/{id} [delete] +func (e UserEndpoint) handleDelete() gin.HandlerFunc { + return func(c *gin.Context) { + // TODO: implement + ctx := domain.SetUserInfoFromGin(c) + + id := Base64UrlDecode(c.Param("id")) + if id == "" { + c.JSON(http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing user id"}) + return + } + + err := e.app.DeleteUser(ctx, domain.UserIdentifier(id)) + if err != nil { + c.JSON(http.StatusInternalServerError, + model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + return + } + + c.Status(http.StatusNoContent) } } diff --git a/internal/app/api/v1/handlers/middleware_authentication.go b/internal/app/api/v1/handlers/middleware_authentication.go index 0b30ce3..514dcab 100644 --- a/internal/app/api/v1/handlers/middleware_authentication.go +++ b/internal/app/api/v1/handlers/middleware_authentication.go @@ -35,7 +35,9 @@ func (h authenticationHandler) LoggedIn(scopes ...Scope) gin.HandlerFunc { } // check if user exists in DB - user, err := h.userSource.GetUser(c.Request.Context(), domain.UserIdentifier(username)) + + ctx := domain.SetUserInfo(c.Request.Context(), domain.SystemAdminContextUserInfo()) + user, err := h.userSource.GetUser(ctx, domain.UserIdentifier(username)) if err != nil { // Abort the request with the appropriate error code c.Abort() diff --git a/internal/app/api/v1/models/models_user.go b/internal/app/api/v1/models/models_user.go index 88105c3..09f1e03 100644 --- a/internal/app/api/v1/models/models_user.go +++ b/internal/app/api/v1/models/models_user.go @@ -1,6 +1,8 @@ package models import ( + "time" + "github.com/h44z/wg-portal/internal/domain" ) @@ -18,18 +20,20 @@ type User struct { Department string `json:"Department"` // The department of the user. This field is optional. Notes string `json:"Notes"` // Additional notes about the user. This field is optional. - Disabled bool `json:"Disabled"` // If this field is set, the user is disabled. - DisabledReason string `json:"DisabledReason"` // The reason why the user has been disabled. - Locked bool `json:"Locked"` // If this field is set, the user is locked and thus unable to log in to WireGuard Portal. - LockedReason string `json:"LockedReason"` // The reason why the user has been locked. + Password string `json:"Password,omitempty"` // The password of the user. This field is never populated on read operations. + Disabled bool `json:"Disabled"` // If this field is set, the user is disabled. + DisabledReason string `json:"DisabledReason"` // The reason why the user has been disabled. + Locked bool `json:"Locked"` // If this field is set, the user is locked and thus unable to log in to WireGuard Portal. + LockedReason string `json:"LockedReason"` // The reason why the user has been locked. - ApiEnabled bool `json:"ApiEnabled"` // If this field is set, the user is allowed to use the RESTful API. + ApiToken string `json:"ApiToken"` // The API token of the user. This field is never populated on bulk read operations. + ApiEnabled bool `json:"ApiEnabled"` // If this field is set, the user is allowed to use the RESTful API. This field is read-only. - PeerCount int `json:"PeerCount"` // The number of peers linked to the user. + PeerCount int `json:"PeerCount"` // The number of peers linked to the user. This field is read-only. } -func NewUser(src *domain.User) *User { - return &User{ +func NewUser(src *domain.User, exposeCredentials bool) *User { + u := &User{ Identifier: string(src.Identifier), Email: src.Email, Source: string(src.Source), @@ -40,21 +44,64 @@ func NewUser(src *domain.User) *User { Phone: src.Phone, Department: src.Department, Notes: src.Notes, + Password: "", // never fill password Disabled: src.IsDisabled(), DisabledReason: src.DisabledReason, Locked: src.IsLocked(), LockedReason: src.LockedReason, + ApiToken: "", // by default, do not expose API token ApiEnabled: src.IsApiEnabled(), - - PeerCount: src.LinkedPeerCount, + PeerCount: src.LinkedPeerCount, } + + if exposeCredentials { + u.ApiToken = src.ApiToken + } + + return u } func NewUsers(src []domain.User) []User { results := make([]User, len(src)) for i := range src { - results[i] = *NewUser(&src[i]) + results[i] = *NewUser(&src[i], false) } return results } + +func NewDomainUser(src *User) *domain.User { + now := time.Now() + res := &domain.User{ + Identifier: domain.UserIdentifier(src.Identifier), + Email: src.Email, + Source: domain.UserSource(src.Source), + ProviderName: src.ProviderName, + IsAdmin: src.IsAdmin, + Firstname: src.Firstname, + Lastname: src.Lastname, + Phone: src.Phone, + Department: src.Department, + Notes: src.Notes, + Password: domain.PrivateString(src.Password), + Disabled: nil, // set below + DisabledReason: src.DisabledReason, + Locked: nil, // set below + LockedReason: src.LockedReason, + } + + if src.ApiToken != "" { + res.ApiToken = src.ApiToken + res.ApiTokenCreated = &now + } + + if src.Disabled { + res.Disabled = &now + } + + if src.Locked { + res.Locked = &now + } + + return res +} diff --git a/internal/app/users/user_manager.go b/internal/app/users/user_manager.go index 946dbdb..9b78974 100644 --- a/internal/app/users/user_manager.go +++ b/internal/app/users/user_manager.go @@ -197,7 +197,7 @@ func (m Manager) CreateUser(ctx context.Context, user *domain.User) (*domain.Use return nil, fmt.Errorf("user %s already exists", user.Identifier) } - if err := m.validateCreation(ctx, user); err != nil { + if err := ValidateCreation(ctx, user); err != nil { return nil, fmt.Errorf("creation not allowed: %w", err) } @@ -328,7 +328,7 @@ func (m Manager) validateModifications(ctx context.Context, old, new *domain.Use return nil } -func (m Manager) validateCreation(ctx context.Context, new *domain.User) error { +func ValidateCreation(ctx context.Context, new *domain.User) error { currentUser := domain.GetUserInfo(ctx) if !currentUser.IsAdmin { @@ -343,8 +343,12 @@ func (m Manager) validateCreation(ctx context.Context, new *domain.User) error { return fmt.Errorf("reserved user identifier") } + if new.Identifier == "new" { // the new user identifier collides with the rest api routes + return fmt.Errorf("reserved user identifier") + } + if new.Source != domain.UserSourceDatabase { - return fmt.Errorf("invalid user source: %s", new.Source) + return fmt.Errorf("invalid user source: %s, only %s is allowed", new.Source, domain.UserSourceDatabase) } if string(new.Password) == "" { diff --git a/internal/domain/errors.go b/internal/domain/errors.go index a428f28..2af5781 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -8,6 +8,8 @@ import ( var ErrNotFound = errors.New("record not found") var ErrNotUnique = errors.New("record not unique") var ErrNoPermission = errors.New("no permission") +var ErrDuplicateEntry = errors.New("duplicate entry") +var ErrInvalidData = errors.New("invalid data") // GetStackTrace returns a stack trace of the current goroutine. The stack trace has at most 1024 bytes. func GetStackTrace() string {