wip: tmp commit

This commit is contained in:
Christoph Haas 2025-01-08 22:41:20 +01:00
parent 612ae742b2
commit 04f87bf37a
10 changed files with 243 additions and 43 deletions

View File

@ -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"
}
}

View File

@ -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"

View File

@ -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"

View File

@ -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
}

View File

@ -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{

View File

@ -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)
}
}

View File

@ -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()

View File

@ -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
}

View File

@ -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) == "" {

View File

@ -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 {