diff --git a/api/auth.go b/api/auth.go index cee390f..487baa4 100644 --- a/api/auth.go +++ b/api/auth.go @@ -5,25 +5,28 @@ import ( "fmt" "net/http" + "github.com/jackc/pgx/v5" "github.com/piquel-fr/api/config" "github.com/piquel-fr/api/services/auth" + "github.com/piquel-fr/api/services/users" "github.com/piquel-fr/api/utils" "github.com/piquel-fr/api/utils/errors" "github.com/piquel-fr/api/utils/middleware" ) type AuthHandler struct { + userService users.UserService authService auth.AuthService } -func CreateAuthHandler(authService auth.AuthService) *AuthHandler { - return &AuthHandler{authService} +func CreateAuthHandler(userService users.UserService, authService auth.AuthService) *AuthHandler { + return &AuthHandler{userService, authService} } func (h *AuthHandler) createHttpHandler() http.Handler { handler := http.NewServeMux() - handler.HandleFunc("GET /policy.json", h.policyHandler) + handler.HandleFunc("GET /policy.json", h.policyHandler) // DEPRECATED TODO: remove handler.HandleFunc("GET /{provider}", h.handleProviderLogin) handler.HandleFunc("GET /{provider}/callback", h.handleAuthCallback) @@ -71,13 +74,16 @@ func (h *AuthHandler) handleAuthCallback(w http.ResponseWriter, r *http.Request) return } - user, err := h.authService.GetUser(r.Context(), oauthUser) + user, err := h.userService.GetUserByEmail(r.Context(), oauthUser.Email) + if errors.Is(err, pgx.ErrNoRows) { + user, err = h.userService.RegisterUser(r.Context(), oauthUser.Username, oauthUser.Email, oauthUser.Name, oauthUser.Image, auth.RoleDefault) + } if err != nil { errors.HandleError(w, r, err) return } - tokenString, err := h.authService.GenerateTokenString(user.ID) + tokenString, err := h.authService.SignToken(h.authService.GenerateToken(user)) if err != nil { errors.HandleError(w, r, err) return diff --git a/api/email.go b/api/email.go index 98fcdcb..69c0f90 100644 --- a/api/email.go +++ b/api/email.go @@ -6,21 +6,24 @@ import ( "strconv" "github.com/getkin/kin-openapi/openapi3" + "github.com/piquel-fr/api/config" "github.com/piquel-fr/api/database" "github.com/piquel-fr/api/database/repository" "github.com/piquel-fr/api/services/auth" "github.com/piquel-fr/api/services/email" + "github.com/piquel-fr/api/services/users" "github.com/piquel-fr/api/utils/errors" "github.com/piquel-fr/api/utils/middleware" ) type EmailHandler struct { + userService users.UserService authService auth.AuthService emailService email.EmailService } -func CreateEmailHandler(authService auth.AuthService, emailService email.EmailService) *EmailHandler { - return &EmailHandler{authService, emailService} +func CreateEmailHandler(userService users.UserService, authService auth.AuthService, emailService email.EmailService) *EmailHandler { + return &EmailHandler{userService, authService, emailService} } func (h *EmailHandler) getName() string { return "email" } @@ -40,11 +43,9 @@ func (h *EmailHandler) getSpec() Spec { WithProperty("username", openapi3.NewStringSchema()). WithProperty("password", openapi3.NewStringSchema()) - spec.Components = &openapi3.Components{ - Schemas: openapi3.Schemas{ - "MailAccount": &openapi3.SchemaRef{Value: accountSchema}, - "AddAccountPayload": &openapi3.SchemaRef{Value: addAccountSchema}, - }, + spec.Components.Schemas = openapi3.Schemas{ + "MailAccount": &openapi3.SchemaRef{Value: accountSchema}, + "AddAccountPayload": &openapi3.SchemaRef{Value: addAccountSchema}, } spec.AddOperation("/", http.MethodGet, &openapi3.Operation{ @@ -235,7 +236,7 @@ func (h *EmailHandler) createHttpHandler() http.Handler { } func (h *EmailHandler) handleListAccounts(w http.ResponseWriter, r *http.Request) { - requester, err := h.authService.GetUserFromRequest(r) + requester, err := h.userService.GetUserFromContext(r.Context()) if err != nil { errors.HandleError(w, r, err) return @@ -243,7 +244,7 @@ func (h *EmailHandler) handleListAccounts(w http.ResponseWriter, r *http.Request var user *repository.User if username := r.URL.Query().Get("user"); username != "" { - user, err = h.authService.GetUserFromUsername(r.Context(), username) + user, err = h.userService.GetUserByUsername(r.Context(), username) if err != nil { errors.HandleError(w, r, err) return @@ -252,7 +253,7 @@ func (h *EmailHandler) handleListAccounts(w http.ResponseWriter, r *http.Request user = requester } - if err := h.authService.Authorize(&auth.Request{ + if err := h.authService.Authorize(&config.AuthRequest{ User: requester, Ressource: user, Context: r.Context(), @@ -296,7 +297,7 @@ func (h *EmailHandler) handleListAccounts(w http.ResponseWriter, r *http.Request } func (h *EmailHandler) handleAddAccount(w http.ResponseWriter, r *http.Request) { - user, err := h.authService.GetUserFromRequest(r) + user, err := h.userService.GetUserFromContext(r.Context()) if err != nil { errors.HandleError(w, r, err) return @@ -321,7 +322,7 @@ func (h *EmailHandler) handleAddAccount(w http.ResponseWriter, r *http.Request) } func (h *EmailHandler) handleAccountInfo(w http.ResponseWriter, r *http.Request) { - user, err := h.authService.GetUserFromRequest(r) + user, err := h.userService.GetUserFromContext(r.Context()) if err != nil { errors.HandleError(w, r, err) return @@ -339,7 +340,7 @@ func (h *EmailHandler) handleAccountInfo(w http.ResponseWriter, r *http.Request) return } - if err := h.authService.Authorize(&auth.Request{ + if err := h.authService.Authorize(&config.AuthRequest{ User: user, Ressource: &accountInfo, Actions: []string{auth.ActionView}, @@ -363,7 +364,7 @@ func (h *EmailHandler) handleAccountInfo(w http.ResponseWriter, r *http.Request) } func (h *EmailHandler) handleRemoveAccount(w http.ResponseWriter, r *http.Request) { - user, err := h.authService.GetUserFromRequest(r) + user, err := h.userService.GetUserFromContext(r.Context()) if err != nil { errors.HandleError(w, r, err) return @@ -375,7 +376,7 @@ func (h *EmailHandler) handleRemoveAccount(w http.ResponseWriter, r *http.Reques return } - if err := h.authService.Authorize(&auth.Request{ + if err := h.authService.Authorize(&config.AuthRequest{ User: user, Ressource: &account, Actions: []string{auth.ActionDelete}, @@ -392,7 +393,7 @@ func (h *EmailHandler) handleRemoveAccount(w http.ResponseWriter, r *http.Reques } func (h *EmailHandler) handleShareAccount(w http.ResponseWriter, r *http.Request) { - user, err := h.authService.GetUserFromRequest(r) + user, err := h.userService.GetUserFromContext(r.Context()) if err != nil { errors.HandleError(w, r, err) return @@ -404,7 +405,7 @@ func (h *EmailHandler) handleShareAccount(w http.ResponseWriter, r *http.Request return } - if err := h.authService.Authorize(&auth.Request{ + if err := h.authService.Authorize(&config.AuthRequest{ User: user, Ressource: &account, Actions: []string{auth.ActionShare}, @@ -414,7 +415,7 @@ func (h *EmailHandler) handleShareAccount(w http.ResponseWriter, r *http.Request return } - sharingUser, err := h.authService.GetUserFromUsername(r.Context(), r.URL.Query().Get("user")) + sharingUser, err := h.userService.GetUserByUsername(r.Context(), r.URL.Query().Get("user")) if err != nil { errors.HandleError(w, r, err) return @@ -433,7 +434,7 @@ func (h *EmailHandler) handleShareAccount(w http.ResponseWriter, r *http.Request } func (h *EmailHandler) handleRemoveAccountShare(w http.ResponseWriter, r *http.Request) { - user, err := h.authService.GetUserFromRequest(r) + user, err := h.userService.GetUserFromContext(r.Context()) if err != nil { errors.HandleError(w, r, err) return @@ -445,7 +446,7 @@ func (h *EmailHandler) handleRemoveAccountShare(w http.ResponseWriter, r *http.R return } - if err := h.authService.Authorize(&auth.Request{ + if err := h.authService.Authorize(&config.AuthRequest{ User: user, Ressource: &account, Actions: []string{auth.ActionShare}, @@ -455,7 +456,7 @@ func (h *EmailHandler) handleRemoveAccountShare(w http.ResponseWriter, r *http.R return } - sharingUser, err := h.authService.GetUserFromUsername(r.Context(), r.URL.Query().Get("user")) + sharingUser, err := h.userService.GetUserByUsername(r.Context(), r.URL.Query().Get("user")) if err != nil { errors.HandleError(w, r, err) return diff --git a/api/handler.go b/api/handler.go index 74f8e20..3914a72 100644 --- a/api/handler.go +++ b/api/handler.go @@ -2,13 +2,16 @@ package api import ( "context" + "encoding/json" "fmt" "log" "net/http" "github.com/getkin/kin-openapi/openapi3" + "github.com/piquel-fr/api/config" "github.com/piquel-fr/api/services/auth" "github.com/piquel-fr/api/services/email" + "github.com/piquel-fr/api/services/users" "github.com/piquel-fr/api/utils/middleware" ) @@ -20,17 +23,24 @@ type Handler interface { createHttpHandler() http.Handler } -func CreateRouter(authService auth.AuthService, emailService email.EmailService) (http.Handler, error) { +func CreateRouter(userService users.UserService, authService auth.AuthService, emailService email.EmailService) (http.Handler, error) { // these routes are unauthenticated and should remail so. // do not any other routes to this router. all other routes // should be added to createProtectedRouter router := http.NewServeMux() router.HandleFunc("/{$}", rootHandler) - router.Handle("/auth/", http.StripPrefix("/auth", CreateAuthHandler(authService).createHttpHandler())) + router.Handle("/auth/", http.StripPrefix("/auth", CreateAuthHandler(userService, authService).createHttpHandler())) + + configHandler, err := configHandler() + if err != nil { + return nil, err + } + router.HandleFunc("/config.json", configHandler) handlers := []Handler{ - CreateProfileHandler(authService), - CreateEmailHandler(authService, emailService), + CreateUserHandler(userService, authService), + CreateProfileHandler(userService, authService), + CreateEmailHandler(userService, authService, emailService), } for _, handler := range handlers { @@ -45,7 +55,7 @@ func CreateRouter(authService auth.AuthService, emailService email.EmailService) // bind the protected router protectedRouter := createProtectedRouter(handlers) - protectedRouter = middleware.AddMiddleware(protectedRouter, middleware.AuthMiddleware(authService)) + protectedRouter = middleware.AddMiddleware(protectedRouter, authService.AuthMiddleware) router.Handle("/", protectedRouter) return middleware.AddMiddleware(router, middleware.CORSMiddleware), nil @@ -68,6 +78,18 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Welcome to the Piquel API! Visit the API for more information.")) } +func configHandler() (http.HandlerFunc, error) { + data, err := json.Marshal(config.GetPublicConfig()) + if err != nil { + return nil, err + } + + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + w.Write(data) + }, nil +} + func newSpecBase(handler Handler) Spec { spec := &openapi3.T{ OpenAPI: "3.0.3", @@ -86,6 +108,22 @@ func newSpecBase(handler Handler) Spec { }, } + securitySchemeName := "bearerAuth" + spec.Components = &openapi3.Components{ + SecuritySchemes: openapi3.SecuritySchemes{ + securitySchemeName: &openapi3.SecuritySchemeRef{ + Value: &openapi3.SecurityScheme{ + Type: "http", + Scheme: "bearer", + BearerFormat: "JWT", + Description: "Enter your bearer token in the format: Bearer ", + }, + }, + }, + } + + spec.Security = openapi3.SecurityRequirements{{securitySchemeName: []string{}}} + spec.AddServer(&openapi3.Server{ URL: fmt.Sprintf("https://api.piquel.fr/%s", handler.getName()), Description: "Main production endpoints", diff --git a/api/profile.go b/api/profile.go index 622e11b..d27e6e8 100644 --- a/api/profile.go +++ b/api/profile.go @@ -5,25 +5,28 @@ import ( "net/http" "github.com/getkin/kin-openapi/openapi3" - "github.com/piquel-fr/api/database" + "github.com/piquel-fr/api/config" "github.com/piquel-fr/api/database/repository" "github.com/piquel-fr/api/services/auth" + "github.com/piquel-fr/api/services/users" "github.com/piquel-fr/api/utils/errors" "github.com/piquel-fr/api/utils/middleware" ) type ProfileHandler struct { + userService users.UserService authService auth.AuthService } -func CreateProfileHandler(authService auth.AuthService) *ProfileHandler { - return &ProfileHandler{authService} +func CreateProfileHandler(userService users.UserService, authService auth.AuthService) *ProfileHandler { + return &ProfileHandler{userService, authService} } func (h *ProfileHandler) getName() string { return "profile" } func (h *ProfileHandler) getSpec() Spec { spec := newSpecBase(h) + spec.Info.Description = "DEPRECATED " + spec.Info.Description userSchema := openapi3.NewObjectSchema(). WithProperty("id", openapi3.NewInt32Schema()). @@ -41,11 +44,9 @@ func (h *ProfileHandler) getSpec() Spec { WithProperty("image", openapi3.NewStringSchema()). WithRequired([]string{"username", "name", "image"}) - spec.Components = &openapi3.Components{ - Schemas: openapi3.Schemas{ - "User": &openapi3.SchemaRef{Value: userSchema}, - "UpdateUserParams": &openapi3.SchemaRef{Value: updateUserSchema}, - }, + spec.Components.Schemas = openapi3.Schemas{ + "User": &openapi3.SchemaRef{Value: userSchema}, + "UpdateUserParams": &openapi3.SchemaRef{Value: updateUserSchema}, } spec.AddOperation("/{user}", http.MethodGet, &openapi3.Operation{ @@ -148,12 +149,7 @@ func (h *ProfileHandler) handleGetProfile(w http.ResponseWriter, r *http.Request func (h *ProfileHandler) handleGetProfileQuery(w http.ResponseWriter, r *http.Request) { username := r.URL.Query().Get("username") if username == "" { - id, err := h.authService.GetUserId(r) - if err != nil { - errors.HandleError(w, r, err) - return - } - user, err := h.authService.GetUserFromUserId(r.Context(), id) + user, err := h.userService.GetUserFromContext(r.Context()) if err != nil { errors.HandleError(w, r, err) return @@ -165,7 +161,7 @@ func (h *ProfileHandler) handleGetProfileQuery(w http.ResponseWriter, r *http.Re } func (h *ProfileHandler) writeProfile(w http.ResponseWriter, r *http.Request, username string) { - user, err := h.authService.GetUserFromUsername(r.Context(), username) + user, err := h.userService.GetUserByUsername(r.Context(), username) if err != nil { errors.HandleError(w, r, err) return @@ -178,13 +174,13 @@ func (h *ProfileHandler) writeProfile(w http.ResponseWriter, r *http.Request, us func (h *ProfileHandler) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { username := r.PathValue("user") - user, err := h.authService.GetUserFromUsername(r.Context(), username) + user, err := h.userService.GetUserByUsername(r.Context(), username) if err != nil { errors.HandleError(w, r, err) return } - request := &auth.Request{ + request := &config.AuthRequest{ User: user, Ressource: user, Actions: []string{auth.ActionUpdate}, @@ -209,7 +205,7 @@ func (h *ProfileHandler) handleUpdateProfile(w http.ResponseWriter, r *http.Requ params.ID = user.ID - if err := database.Queries.UpdateUser(r.Context(), params); err != nil { + if err := h.userService.UpdateUser(r.Context(), params); err != nil { errors.HandleError(w, r, err) return } diff --git a/api/users.go b/api/users.go new file mode 100644 index 0000000..dd2c65f --- /dev/null +++ b/api/users.go @@ -0,0 +1,364 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/piquel-fr/api/config" + "github.com/piquel-fr/api/database/repository" + "github.com/piquel-fr/api/services/auth" + "github.com/piquel-fr/api/services/users" + "github.com/piquel-fr/api/utils/errors" + "github.com/piquel-fr/api/utils/middleware" +) + +type UserHandler struct { + userService users.UserService + authService auth.AuthService +} + +func CreateUserHandler(userService users.UserService, authService auth.AuthService) *UserHandler { + return &UserHandler{userService, authService} +} + +func (h *UserHandler) getName() string { return "users" } + +func (h *UserHandler) getSpec() Spec { + spec := newSpecBase(h) + + userSchema := openapi3.NewObjectSchema(). + WithProperty("id", openapi3.NewInt32Schema()). + WithProperty("username", openapi3.NewStringSchema()). + WithProperty("name", openapi3.NewStringSchema()). + WithProperty("image", openapi3.NewStringSchema()). + WithProperty("email", openapi3.NewStringSchema().WithFormat("email")). + WithProperty("role", openapi3.NewStringSchema()). + WithProperty("createdAt", openapi3.NewDateTimeSchema()). + WithRequired([]string{"id", "username", "name", "image", "role", "createdAt"}) + + updateUserSchema := openapi3.NewObjectSchema(). + WithProperty("username", openapi3.NewStringSchema()). + WithProperty("name", openapi3.NewStringSchema()). + WithProperty("image", openapi3.NewStringSchema()). + WithRequired([]string{"username", "name", "image"}) + + updateUserAdminSchema := openapi3.NewObjectSchema(). + WithProperty("username", openapi3.NewStringSchema()). + WithProperty("name", openapi3.NewStringSchema()). + WithProperty("image", openapi3.NewStringSchema()). + WithProperty("email", openapi3.NewStringSchema().WithFormat("email")). + WithProperty("role", openapi3.NewStringSchema()). + WithRequired([]string{"username", "name", "image", "email", "role"}) + + spec.Components.Schemas = openapi3.Schemas{ + "User": &openapi3.SchemaRef{Value: userSchema}, + "UpdateUserParams": &openapi3.SchemaRef{Value: updateUserSchema}, + "UpdateUserAdminParams": &openapi3.SchemaRef{Value: updateUserAdminSchema}, + } + + spec.AddOperation("/self", http.MethodGet, &openapi3.Operation{ + Tags: []string{"users"}, + Summary: "Get self user object", + Description: "Get the user that is associated with the auth", + OperationID: "get-self", + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{ + Value: openapi3.NewResponse().WithDescription("User profile found").WithJSONSchemaRef(openapi3.NewSchemaRef("#/components/schemas/User", userSchema)), + }), + ), + }) + + spec.AddOperation("/{user}", http.MethodGet, &openapi3.Operation{ + Tags: []string{"users"}, + Summary: "Get specific user", + Description: "Get the profile of the user specified in the path", + OperationID: "get-user-by-path", + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "user", + In: "path", + Required: true, + Description: "The username", + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + }, + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{ + Value: openapi3.NewResponse().WithDescription("User profile found").WithJSONSchemaRef(openapi3.NewSchemaRef("#/components/schemas/User", userSchema)), + }), + ), + }) + + spec.AddOperation("/{user}", http.MethodPut, &openapi3.Operation{ + Tags: []string{"users"}, + Summary: "Update user", + Description: "Update the profile of the specified user", + OperationID: "update-user", + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "user", + In: "path", + Required: true, + Description: "The username to update", + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + }, + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Required: true, + Content: openapi3.NewContentWithJSONSchemaRef( + openapi3.NewSchemaRef("#/components/schemas/UpdateUserParams", updateUserSchema), + ), + }, + }, + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{Value: openapi3.NewResponse().WithDescription("User updated successfully")}), + openapi3.WithStatus(400, &openapi3.ResponseRef{Value: openapi3.NewResponse().WithContent(openapi3.NewContentWithSchema(openapi3.NewStringSchema(), []string{"text/plain"})).WithDescription("Invalid input or json")}), + ), + }) + + spec.AddOperation("/{user}", http.MethodDelete, &openapi3.Operation{ + Tags: []string{"users"}, + Summary: "Delete user", + Description: "Delete the user specified in the path", + OperationID: "delete-user", + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "user", + In: "path", + Required: true, + Description: "The username", + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + }, + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{Value: openapi3.NewResponse().WithDescription("User deleted successfully")}), + openapi3.WithStatus(401, &openapi3.ResponseRef{Value: openapi3.NewResponse().WithContent(openapi3.NewContentWithSchema(openapi3.NewStringSchema(), []string{"text/plain"})).WithDescription("Unauthorized")}), + ), + }) + + spec.AddOperation("/{user}/admin", http.MethodPut, &openapi3.Operation{ + Tags: []string{"users", "admin"}, + Summary: "Update user", + Description: "Update the profile of the specified user", + OperationID: "update-user-admin", + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "user", + In: "path", + Required: true, + Description: "The username to update", + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + }, + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Required: true, + Content: openapi3.NewContentWithJSONSchemaRef( + openapi3.NewSchemaRef("#/components/schemas/UpdateUserAdminParams", updateUserAdminSchema), + ), + }, + }, + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{Value: openapi3.NewResponse().WithDescription("User updated successfully")}), + openapi3.WithStatus(400, &openapi3.ResponseRef{Value: openapi3.NewResponse().WithContent(openapi3.NewContentWithSchema(openapi3.NewStringSchema(), []string{"text/plain"})).WithDescription("Invalid input or json")}), + ), + }) + + return spec +} + +func (h *UserHandler) createHttpHandler() http.Handler { + handler := http.NewServeMux() + + handler.HandleFunc("GET /self", h.handleGetSelf) + handler.HandleFunc("GET /{user}", h.handleGetUser) + handler.HandleFunc("PUT /{user}", h.handlePutUser) + handler.HandleFunc("DELETE /{user}", h.handleDeleteUser) + handler.HandleFunc("PUT /{user}/admin", h.handlePutUserAdmin) + + handler.Handle("OPTIONS /self", middleware.CreateOptionsHandler("GET")) + handler.Handle("OPTIONS /{user}", middleware.CreateOptionsHandler("GET", "PUT", "DELETE")) + handler.Handle("OPTIONS /{user}/admin", middleware.CreateOptionsHandler("PUT")) + + return handler +} + +func (h *UserHandler) handleGetSelf(w http.ResponseWriter, r *http.Request) { + user, err := h.userService.GetUserFromContext(r.Context()) + if err != nil { + errors.HandleError(w, r, err) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(user) +} + +func (h *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("user") + user, err := h.userService.GetUserByUsername(r.Context(), username) + if err != nil { + errors.HandleError(w, r, err) + return + } + + requester, err := h.userService.GetUserFromContext(r.Context()) + if err != nil { + errors.HandleError(w, r, err) + return + } + + request := &config.AuthRequest{ + User: requester, + Ressource: user, + Actions: []string{auth.ActionViewEmail}, + Context: r.Context(), + } + + if err := h.authService.Authorize(request); err == errors.ErrorForbidden { + user.Email = "" + } else if err != nil { + errors.HandleError(w, r, err) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(user) +} + +func (h *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("user") + + user, err := h.userService.GetUserByUsername(r.Context(), username) + if err != nil { + errors.HandleError(w, r, err) + return + } + + requester, err := h.userService.GetUserFromContext(r.Context()) + if err != nil { + errors.HandleError(w, r, err) + return + } + + request := &config.AuthRequest{ + User: requester, + Ressource: user, + Actions: []string{auth.ActionUpdate}, + Context: r.Context(), + } + + if err := h.authService.Authorize(request); err != nil { + errors.HandleError(w, r, err) + return + } + + if r.Header.Get("Content-Type") != "application/json" { + http.Error(w, "please submit your creation request with the required json payload", http.StatusBadRequest) + return + } + + params := repository.UpdateUserParams{} + if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { + errors.HandleError(w, r, err) + return + } + + params.ID = user.ID + + if err := h.userService.UpdateUser(r.Context(), params); err != nil { + errors.HandleError(w, r, err) + return + } + w.WriteHeader(http.StatusOK) +} + +func (h *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("user") + user, err := h.userService.GetUserByUsername(r.Context(), username) + if err != nil { + errors.HandleError(w, r, err) + return + } + + requester, err := h.userService.GetUserFromContext(r.Context()) + if err != nil { + errors.HandleError(w, r, err) + return + } + + request := &config.AuthRequest{ + User: requester, + Ressource: user, + Actions: []string{auth.ActionDelete}, + Context: r.Context(), + } + + if err := h.authService.Authorize(request); err != nil { + errors.HandleError(w, r, err) + return + } + + if err := h.userService.DeleteUser(r.Context(), user); err != nil { + errors.HandleError(w, r, err) + return + } + w.WriteHeader(http.StatusOK) +} + +func (h *UserHandler) handlePutUserAdmin(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("user") + + user, err := h.userService.GetUserByUsername(r.Context(), username) + if err != nil { + errors.HandleError(w, r, err) + return + } + + requester, err := h.userService.GetUserFromContext(r.Context()) + if err != nil { + errors.HandleError(w, r, err) + return + } + + request := &config.AuthRequest{ + User: requester, + Ressource: user, + Actions: []string{auth.ActionUpdateAdmin}, + Context: r.Context(), + } + + if err := h.authService.Authorize(request); err != nil { + errors.HandleError(w, r, err) + return + } + + if r.Header.Get("Content-Type") != "application/json" { + http.Error(w, "please submit your creation request with the required json payload", http.StatusBadRequest) + return + } + + params := repository.UpdateUserAdminParams{} + if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { + errors.HandleError(w, r, err) + return + } + + params.ID = user.ID + + if err := h.userService.UpdateUserAdmin(r.Context(), params); err != nil { + errors.HandleError(w, r, err) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/config/config.go b/config/config.go index 5240af4..d5125f4 100644 --- a/config/config.go +++ b/config/config.go @@ -28,9 +28,23 @@ type EnvsConfig struct { ImapPort string } +type PublicConfig struct { + Policy *PolicyConfiguration `json:"policy"` + UsernameBlacklist []string `json:"username_blacklist"` +} + +func GetPublicConfig() PublicConfig { + return PublicConfig{Policy, UsernameBlacklist} +} + var Envs EnvsConfig var MaxDocsInstanceCount int64 = 3 var JWTSigningMethod jwt.SigningMethod = jwt.SigningMethodHS256 +var UserContextKey = "user" + +// these are populated by external services +var UsernameBlacklist []string +var Policy *PolicyConfiguration func LoadConfig() { godotenv.Load() diff --git a/services/auth/models.go b/config/policy.go similarity index 60% rename from services/auth/models.go rename to config/policy.go index 06af735..5fb19b3 100644 --- a/services/auth/models.go +++ b/config/policy.go @@ -1,14 +1,25 @@ -package auth +package config import ( "context" + "fmt" + "net/http" "github.com/piquel-fr/api/database/repository" + "github.com/piquel-fr/api/utils/errors" ) type PolicyConfiguration struct { Presets map[string]*Permission `json:"presets"` - Roles Roles `json:"roles"` + Roles map[string]*Role `json:"roles"` +} + +func (p *PolicyConfiguration) ValidateRole(role string) error { + _, ok := p.Roles[role] + if !ok { + return errors.NewError(fmt.Sprintf("role %s does not exist in current policy", role), http.StatusBadRequest) + } + return nil } type Permission struct { @@ -17,16 +28,16 @@ type Permission struct { Preset string `json:"preset"` } -type Conditions []func(request *Request) error +type Conditions []func(request *AuthRequest) error -type Roles map[string]*struct { +type Role struct { Name string `json:"name"` Color string `json:"color"` Permissions map[string][]*Permission `json:"permissions"` Parents []string `json:"parents"` } -type Request struct { +type AuthRequest struct { User *repository.User Ressource Resource Actions []string diff --git a/database/queries/users.sql b/database/queries/users.sql index 607073f..161b008 100644 --- a/database/queries/users.sql +++ b/database/queries/users.sql @@ -11,5 +11,14 @@ SELECT * FROM "users" WHERE "id" = $1; -- name: GetUserByEmail :one SELECT * FROM "users" WHERE "email" = $1; +-- name: ListUsers :many +SELECT * FROM "users" ORDER BY "id" ASC LIMIT $1 OFFSET $2; + +-- name: ListUserNames :many +SELECT "username" FROM "users"; + -- name: UpdateUser :exec UPDATE "users" SET "username" = $2, "name" = $3, "image" = $4 WHERE "id" = $1; + +-- name: UpdateUserAdmin :exec +UPDATE "users" SET "username" = $2, "email" = $3, "name" = $4, "image" = $5, "role" = $6 WHERE "id" = $1; diff --git a/database/repository/users.sql.go b/database/repository/users.sql.go index 1c1dd35..bffaddb 100644 --- a/database/repository/users.sql.go +++ b/database/repository/users.sql.go @@ -100,6 +100,67 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, return i, err } +const listUserNames = `-- name: ListUserNames :many +SELECT "username" FROM "users" +` + +func (q *Queries) ListUserNames(ctx context.Context) ([]string, error) { + rows, err := q.db.Query(ctx, listUserNames) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var username string + if err := rows.Scan(&username); err != nil { + return nil, err + } + items = append(items, username) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUsers = `-- name: ListUsers :many +SELECT id, username, name, image, email, role, "createdAt" FROM "users" ORDER BY "id" ASC LIMIT $1 OFFSET $2 +` + +type ListUsersParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]User, error) { + rows, err := q.db.Query(ctx, listUsers, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Username, + &i.Name, + &i.Image, + &i.Email, + &i.Role, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateUser = `-- name: UpdateUser :exec UPDATE "users" SET "username" = $2, "name" = $3, "image" = $4 WHERE "id" = $1 ` @@ -120,3 +181,28 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { ) return err } + +const updateUserAdmin = `-- name: UpdateUserAdmin :exec +UPDATE "users" SET "username" = $2, "email" = $3, "name" = $4, "image" = $5, "role" = $6 WHERE "id" = $1 +` + +type UpdateUserAdminParams struct { + ID int32 `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Name string `json:"name"` + Image string `json:"image"` + Role string `json:"role"` +} + +func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams) error { + _, err := q.db.Exec(ctx, updateUserAdmin, + arg.ID, + arg.Username, + arg.Email, + arg.Name, + arg.Image, + arg.Role, + ) + return err +} diff --git a/main.go b/main.go index b9b92af..f551813 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "github.com/piquel-fr/api/database" "github.com/piquel-fr/api/services/auth" "github.com/piquel-fr/api/services/email" + "github.com/piquel-fr/api/services/users" gh "github.com/piquel-fr/api/utils/github" "github.com/piquel-fr/api/utils/oauth" ) @@ -24,10 +25,14 @@ func main() { database.InitDatabase() defer database.Connection.Close() - authService := auth.NewRealAuthService() + userService := users.NewRealUserService() + authService := auth.NewRealAuthService(userService) emailService := email.NewRealEmailService() - router, err := api.CreateRouter(authService, emailService) + config.UsernameBlacklist = userService.GetUsernameBlacklist() + config.Policy = authService.GetPolicy() + + router, err := api.CreateRouter(userService, authService, emailService) if err != nil { panic(err) } diff --git a/services/auth/auth.go b/services/auth/auth.go index 58ee9dc..ae05495 100644 --- a/services/auth/auth.go +++ b/services/auth/auth.go @@ -8,48 +8,60 @@ import ( "strings" "github.com/golang-jwt/jwt/v5" - "github.com/jackc/pgx/v5" "github.com/piquel-fr/api/config" - "github.com/piquel-fr/api/database" "github.com/piquel-fr/api/database/repository" + "github.com/piquel-fr/api/services/users" "github.com/piquel-fr/api/utils/errors" "github.com/piquel-fr/api/utils/oauth" ) type AuthService interface { - GenerateTokenString(userId int32) (string, error) - GetToken(r *http.Request) (*jwt.Token, error) - GetUserId(r *http.Request) (int32, error) - GetUser(ctx context.Context, inUser *oauth.User) (*repository.User, error) - GetUserFromRequest(r *http.Request) (*repository.User, error) - GetUserFromUserId(ctx context.Context, userId int32) (*repository.User, error) - GetUserFromUsername(ctx context.Context, username string) (*repository.User, error) - Authorize(request *Request) error + GetPolicy() *config.PolicyConfiguration GetProvider(name string) (oauth.Provider, error) - GetPolicy() *PolicyConfiguration + // token management + GenerateToken(user *repository.User) *jwt.Token // TODO: also save expiry and refresh + SignToken(token *jwt.Token) (string, error) + getTokenFromRequest(r *http.Request) (*jwt.Token, error) + getUserFromToken(ctx context.Context, token *jwt.Token) (*repository.User, error) + + // authorization + Authorize(request *config.AuthRequest) error + AuthMiddleware(next http.Handler) http.Handler } -// auth service has no state -type realAuthService struct{} +type realAuthService struct { + userService users.UserService +} -func NewRealAuthService() *realAuthService { - return &realAuthService{} +func NewRealAuthService(userService users.UserService) *realAuthService { + return &realAuthService{userService} } -func (s *realAuthService) GetPolicy() *PolicyConfiguration { return &policy } +func (s *realAuthService) GetPolicy() *config.PolicyConfiguration { return &policy } + +func (s *realAuthService) GetProvider(name string) (oauth.Provider, error) { + provider, ok := oauth.Providers[name] + if !ok { + return nil, errors.NewError(fmt.Sprintf("provider %s does not exist", name), http.StatusBadRequest) + } + return provider, nil +} -func (s *realAuthService) GenerateTokenString(userId int32) (string, error) { - idString := strconv.Itoa(int(userId)) - token := jwt.NewWithClaims(config.JWTSigningMethod, +// TODO: also save expiry and refresh +func (s *realAuthService) GenerateToken(user *repository.User) *jwt.Token { + idString := strconv.Itoa(int(user.ID)) + return jwt.NewWithClaims(config.JWTSigningMethod, jwt.RegisteredClaims{ Subject: idString, }) +} +func (s *realAuthService) SignToken(token *jwt.Token) (string, error) { return token.SignedString(config.Envs.JWTSigningSecret) } -func (s *realAuthService) GetToken(r *http.Request) (*jwt.Token, error) { +func (s *realAuthService) getTokenFromRequest(r *http.Request) (*jwt.Token, error) { authHeader := r.Header.Get("Authorization") parts := strings.Split(authHeader, " ") if len(parts) != 2 || parts[0] != "Bearer" { @@ -62,74 +74,40 @@ func (s *realAuthService) GetToken(r *http.Request) (*jwt.Token, error) { }) } -func (s *realAuthService) GetUserId(r *http.Request) (int32, error) { - token, err := s.GetToken(r) - if err != nil { - return 0, err - } - +func (s *realAuthService) getUserFromToken(ctx context.Context, token *jwt.Token) (*repository.User, error) { subject, err := token.Claims.GetSubject() if err != nil { - return 0, err + return nil, err } id, err := strconv.Atoi(subject) - if err != nil { - return 0, err - } - - return int32(id), nil -} - -func (s *realAuthService) GetUserFromRequest(r *http.Request) (*repository.User, error) { - userId, err := s.GetUserId(r) if err != nil { return nil, err } - user, err := database.Queries.GetUserById(r.Context(), userId) - return &user, err + return s.userService.GetUserById(ctx, int32(id)) } -func (s *realAuthService) GetUser(ctx context.Context, inUser *oauth.User) (*repository.User, error) { - user, err := database.Queries.GetUserByEmail(ctx, inUser.Email) - if err != nil { - if err == pgx.ErrNoRows { - return s.registerUser(ctx, inUser) +func (s *realAuthService) AuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + return } - return nil, err - } - - return &user, nil -} -func (s *realAuthService) registerUser(ctx context.Context, inUser *oauth.User) (*repository.User, error) { - params := repository.AddUserParams{} - - params.Email = inUser.Email - params.Username = inUser.Username - params.Role = RoleDefault - params.Image = inUser.Image - params.Name = inUser.Name - - user, err := database.Queries.AddUser(ctx, params) - return &user, err -} - -func (s *realAuthService) GetUserFromUsername(ctx context.Context, username string) (*repository.User, error) { - user, err := database.Queries.GetUserByUsername(ctx, username) - return &user, err -} + token, err := s.getTokenFromRequest(r) + if err != nil { + errors.HandleError(w, r, err) + return + } -func (s *realAuthService) GetUserFromUserId(ctx context.Context, userId int32) (*repository.User, error) { - user, err := database.Queries.GetUserById(ctx, userId) - return &user, err -} + user, err := s.getUserFromToken(r.Context(), token) + if err != nil { + errors.HandleError(w, r, err) + return + } -func (s *realAuthService) GetProvider(name string) (oauth.Provider, error) { - provider, ok := oauth.Providers[name] - if !ok { - return nil, errors.NewError(fmt.Sprintf("provider %s does not exist", name), http.StatusBadRequest) - } - return provider, nil + newReq := r.WithContext(context.WithValue(r.Context(), config.UserContextKey, user)) + next.ServeHTTP(w, newReq) + }) } diff --git a/services/auth/errors.go b/services/auth/errors.go index fef2ca5..df23948 100644 --- a/services/auth/errors.go +++ b/services/auth/errors.go @@ -2,9 +2,11 @@ package auth import ( "fmt" + + "github.com/piquel-fr/api/config" ) -func newRequestMalformedError(request *Request) error { +func newRequestMalformedError(request *config.AuthRequest) error { return fmt.Errorf("Request is malformed: %v", request) } diff --git a/services/auth/permissions.go b/services/auth/permissions.go index 6eeba42..2712c6e 100644 --- a/services/auth/permissions.go +++ b/services/auth/permissions.go @@ -3,10 +3,11 @@ package auth import ( "slices" + "github.com/piquel-fr/api/config" "github.com/piquel-fr/api/utils/errors" ) -func (s *realAuthService) Authorize(request *Request) error { +func (s *realAuthService) Authorize(request *config.AuthRequest) error { if request.User == nil || request.Ressource == nil { return newRequestMalformedError(request) } @@ -35,16 +36,16 @@ func (s *realAuthService) Authorize(request *Request) error { return errors.ErrorForbidden } -func (s *realAuthService) authorize(request *Request, roleName, resourceName string, checkedRoles []string) (bool, error) { +func (s *realAuthService) authorize(request *config.AuthRequest, roleName, resourceName string, checkedRoles []string) (bool, error) { role, ok := policy.Roles[roleName] if !ok { return false, newRoleNotFoundError(roleName) } - var permissions []*Permission + var permissions []*config.Permission if role.Permissions == nil { - permissions = []*Permission{} + permissions = []*config.Permission{} } else { permissions = role.Permissions[resourceName] } @@ -65,7 +66,7 @@ func (s *realAuthService) authorize(request *Request, roleName, resourceName str checkedRoles = append(checkedRoles, roleName) for _, parent := range parents { - parentRequest := &Request{ + parentRequest := &config.AuthRequest{ User: request.User, Ressource: request.Ressource, Actions: []string{action}, @@ -95,7 +96,7 @@ func (s *realAuthService) authorize(request *Request, roleName, resourceName str return true, nil } -func (s *realAuthService) validateAction(permissions []*Permission, action string, request *Request) (bool, error) { +func (s *realAuthService) validateAction(permissions []*config.Permission, action string, request *config.AuthRequest) (bool, error) { for _, permission := range permissions { if permission.Preset != "" { @@ -123,7 +124,7 @@ func (s *realAuthService) validateAction(permissions []*Permission, action strin return false, nil } -func (s *realAuthService) checkPermission(permission *Permission, request *Request) (bool, error) { +func (s *realAuthService) checkPermission(permission *config.Permission, request *config.AuthRequest) (bool, error) { if permission.Conditions == nil { return true, nil } diff --git a/services/auth/policy.go b/services/auth/policy.go index 4928ef7..ae1e5e8 100644 --- a/services/auth/policy.go +++ b/services/auth/policy.go @@ -3,6 +3,7 @@ package auth import ( "slices" + "github.com/piquel-fr/api/config" "github.com/piquel-fr/api/database/repository" "github.com/piquel-fr/api/services/email" "github.com/piquel-fr/api/utils/errors" @@ -16,45 +17,49 @@ const ( ) const ( - ActionView string = "view" - ActionCreate string = "create" - ActionUpdate string = "update" - ActionDelete string = "delete" - ActionShare string = "share" + ActionView = "view" + ActionCreate = "create" + ActionUpdate = "update" + ActionDelete = "delete" + ActionShare = "share" - ActionListEmailAccounts string = "list_email_accounts" + ActionUpdateAdmin = "update_admin" + ActionViewEmail = "view_email" + ActionListEmailAccounts = "list_email_accounts" ) -func own(request *Request) error { +func own(request *config.AuthRequest) error { if request.Ressource.GetOwner() == request.User.ID { return nil } return errors.ErrorForbidden } -func makeOwn(action string) *Permission { - return &Permission{ +func makeOwn(action string) *config.Permission { + return &config.Permission{ Action: action, - Conditions: Conditions{own}, + Conditions: config.Conditions{own}, } } -var policy = PolicyConfiguration{ - Presets: map[string]*Permission{}, - Roles: Roles{ +var policy = config.PolicyConfiguration{ + Presets: map[string]*config.Permission{}, + Roles: map[string]*config.Role{ RoleSystem: { Name: "System", Color: "gray", - Permissions: map[string][]*Permission{}, + Permissions: map[string][]*config.Permission{}, Parents: []string{RoleDefault, RoleDeveloper, RoleAdmin}, }, RoleAdmin: { Name: "Admin", Color: "red", - Permissions: map[string][]*Permission{ + Permissions: map[string][]*config.Permission{ repository.ResourceUser: { {Action: ActionUpdate}, {Action: ActionDelete}, + {Action: ActionViewEmail}, + {Action: ActionUpdateAdmin}, }, repository.ResourceMailAccount: { {Action: ActionView}, @@ -69,12 +74,12 @@ var policy = PolicyConfiguration{ RoleDeveloper: { Name: "Developer", Color: "blue", - Permissions: map[string][]*Permission{ + Permissions: map[string][]*config.Permission{ repository.ResourceMailAccount: { { Action: ActionView, - Conditions: Conditions{ - func(request *Request) error { + Conditions: config.Conditions{ + func(request *config.AuthRequest) error { if request.Ressource.GetOwner() == request.User.ID { return nil } @@ -103,10 +108,11 @@ var policy = PolicyConfiguration{ RoleDefault: { Name: "", Color: "gray", - Permissions: map[string][]*Permission{ + Permissions: map[string][]*config.Permission{ repository.ResourceUser: { makeOwn(ActionUpdate), makeOwn(ActionDelete), + makeOwn(ActionViewEmail), }, }, }, diff --git a/services/users/users.go b/services/users/users.go new file mode 100644 index 0000000..2e201c6 --- /dev/null +++ b/services/users/users.go @@ -0,0 +1,193 @@ +package users + +import ( + "context" + "crypto/rand" + "fmt" + "net/http" + "regexp" + "slices" + "strings" + + "github.com/piquel-fr/api/config" + "github.com/piquel-fr/api/database" + "github.com/piquel-fr/api/database/repository" + "github.com/piquel-fr/api/utils/errors" +) + +type UserService interface { + GetUsernameBlacklist() []string + + // getting the user + GetUserById(ctx context.Context, id int32) (*repository.User, error) + GetUserByUsername(ctx context.Context, username string) (*repository.User, error) + GetUserByEmail(ctx context.Context, email string) (*repository.User, error) + GetUserFromContext(ctx context.Context) (*repository.User, error) + + // managing users + UpdateUser(ctx context.Context, params repository.UpdateUserParams) error + UpdateUserAdmin(ctx context.Context, params repository.UpdateUserAdminParams) error + RegisterUser(ctx context.Context, username, email, name, image, role string) (*repository.User, error) + DeleteUser(ctx context.Context, user *repository.User) error + + // other + ListUsers(ctx context.Context, offset, limit int32) ([]repository.User, error) +} + +type realUserService struct{} + +func NewRealUserService() *realUserService { + return &realUserService{} +} + +func (s *realUserService) GetUserById(ctx context.Context, id int32) (*repository.User, error) { + user, err := database.Queries.GetUserById(ctx, id) + return &user, err +} + +func (s *realUserService) GetUserByUsername(ctx context.Context, username string) (*repository.User, error) { + user, err := database.Queries.GetUserByUsername(ctx, username) + return &user, err +} + +func (s *realUserService) GetUserByEmail(ctx context.Context, email string) (*repository.User, error) { + user, err := database.Queries.GetUserByEmail(ctx, email) + return &user, err +} + +func (s *realUserService) GetUserFromContext(ctx context.Context) (*repository.User, error) { + user, ok := ctx.Value(config.UserContextKey).(*repository.User) + if !ok { + return nil, fmt.Errorf("user is not in context") + } + return user, nil +} + +func (s *realUserService) UpdateUser(ctx context.Context, params repository.UpdateUserParams) error { + username, err := s.formatAndValidateUsername(ctx, params.Username, false) + if err != nil { + return err + } + params.Username = username + + return database.Queries.UpdateUser(ctx, params) +} + +func (s *realUserService) UpdateUserAdmin(ctx context.Context, params repository.UpdateUserAdminParams) error { + username, err := s.formatAndValidateUsername(ctx, params.Username, false) + if err != nil { + return err + } + params.Username = username + + if err := config.Policy.ValidateRole(params.Role); err != nil { + return err + } + + return database.Queries.UpdateUserAdmin(ctx, params) +} + +func (s *realUserService) RegisterUser(ctx context.Context, username, email, name, image, role string) (*repository.User, error) { + username, err := s.formatAndValidateUsername(ctx, username, true) + if err != nil { + return nil, err + } + + if err := config.Policy.ValidateRole(role); err != nil { + return nil, err + } + + params := repository.AddUserParams{ + Username: username, + Email: email, + Name: name, + Image: image, + Role: role, + } + + user, err := database.Queries.AddUser(ctx, params) + return &user, err +} + +func (s *realUserService) DeleteUser(ctx context.Context, user *repository.User) error { + // TODO: delete user + return nil +} + +// @param force: if the validation can fail. When creating a new user through OAuth, user creation cannot fail. We will thus create a random one +func (s *realUserService) formatAndValidateUsername(ctx context.Context, username string, force bool) (string, error) { + // check if username actually changing + user, err := s.GetUserFromContext(ctx) + if err != nil { + return "", err + } + + if user.Username == username { + return username, nil + } + + // formatting + random := false + username = strings.ReplaceAll(strings.ToLower(username), " ", "") + + // blacklist + if slices.Contains(config.UsernameBlacklist, username) { + random = true + if !force { + return "", errors.NewError(fmt.Sprintf("username %s is not legal", username), http.StatusBadRequest) + } + } + + // regex + matched, err := regexp.MatchString("^[a-z0-9]+$", username) + if !matched { + random = true + if !force { + return "", errors.NewError(fmt.Sprintf("username %s contains illegal characters. only letters and numbers are allowed", username), http.StatusBadRequest) + } + } + if err != nil { + random = true + if !force { + return "", fmt.Errorf("error matching regex in username validation: %w", err) + } + } + + // already existing users + names, err := database.Queries.ListUserNames(ctx) + if err != nil { + random = true + if !force { + return "", err + } + } + + if slices.Contains(names, username) { + random = true + if !force { + return "", errors.NewError(fmt.Sprintf("username %s is already taken", username), http.StatusBadRequest) + } + } + + // random generation + if random { + username = rand.Text() + username, err = s.formatAndValidateUsername(ctx, username, true) + if err != nil { + return "", fmt.Errorf("something terrible happened in username validation:\n\tusername: %s\n\terror: %w", username, err) + } + } + + return username, nil +} + +func (s *realUserService) ListUsers(ctx context.Context, offset, limit int32) ([]repository.User, error) { + if limit > 200 { + limit = 200 + } + return database.Queries.ListUsers(ctx, repository.ListUsersParams{Offset: offset, Limit: limit}) +} + +func (s *realUserService) GetUsernameBlacklist() []string { + return []string{"self", "root", "users", "admin", "system"} // TODO: add more +} diff --git a/utils/errors/errors.go b/utils/errors/errors.go index bc6e19b..4914249 100644 --- a/utils/errors/errors.go +++ b/utils/errors/errors.go @@ -29,6 +29,10 @@ func (e *Error) Error() string { return e.message } +func Is(err, target error) bool { + return errors.Is(err, target) +} + func getError(err error) *Error { if err == nil { panic("nil error being handled") diff --git a/utils/middleware/middleware.go b/utils/middleware/middleware.go index da78242..2182876 100644 --- a/utils/middleware/middleware.go +++ b/utils/middleware/middleware.go @@ -3,9 +3,6 @@ package middleware import ( "net/http" "strings" - - "github.com/piquel-fr/api/services/auth" - "github.com/piquel-fr/api/utils/errors" ) type Middleware func(http.Handler) http.Handler @@ -42,24 +39,6 @@ func CORSMiddleware(next http.Handler) http.Handler { }) } -func AuthMiddleware(auth auth.AuthService) Middleware { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodOptions { - next.ServeHTTP(w, r) - return - } - - _, err := auth.GetToken(r) - if err != nil { - errors.HandleError(w, r, err) - return - } - next.ServeHTTP(w, r) - }) - } -} - func CreateOptionsHandler(methods ...string) http.Handler { methods = append(methods, "OPTIONS") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/utils/oauth/github.go b/utils/oauth/github.go index 5f0a8e9..dab0014 100644 --- a/utils/oauth/github.go +++ b/utils/oauth/github.go @@ -7,7 +7,6 @@ import ( "net/http" "strings" - "github.com/piquel-fr/api/utils" "github.com/piquel-fr/api/utils/errors" "golang.org/x/oauth2" ) @@ -55,7 +54,7 @@ func (gh *github) FetchUser(context context.Context, token *oauth2.Token) (*User Name: u.Name, Email: u.Email, Image: u.Picture, - Username: utils.FormatUsername(u.Login), + Username: u.Login, // will be formated by user service } if user.Email == "" { diff --git a/utils/oauth/google.go b/utils/oauth/google.go index 8fb7d0e..555e680 100644 --- a/utils/oauth/google.go +++ b/utils/oauth/google.go @@ -7,7 +7,6 @@ import ( "net/http" "net/url" - "github.com/piquel-fr/api/utils" "golang.org/x/oauth2" ) @@ -48,7 +47,7 @@ func (gg *google) FetchUser(context context.Context, token *oauth2.Token) (*User Name: u.Name, Email: u.Email, Image: u.Picture, - Username: utils.FormatUsername(u.Name), + Username: u.Name, // will be formated by the users service } return user, nil diff --git a/utils/users.go b/utils/users.go deleted file mode 100644 index f5d328b..0000000 --- a/utils/users.go +++ /dev/null @@ -1,7 +0,0 @@ -package utils - -import "strings" - -func FormatUsername(username string) string { - return strings.ReplaceAll(strings.ToLower(username), " ", "") -}