mirror of
https://github.com/h44z/wg-portal
synced 2025-06-26 18:16:21 +00:00
wip: tmp commit
This commit is contained in:
parent
612ae742b2
commit
04f87bf37a
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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{
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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.
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -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) == "" {
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user