From 73f7fe63533972add0f52e250664cd6478e87ee2 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Fri, 25 Nov 2022 05:25:39 +0400 Subject: [PATCH 01/92] dAPI auth and OpenAPI schema for it --- auth.go | 8 +- d_api.go | 1 - d_api_auth.go | 3 +- d_api_change_password.go | 48 +++ d_api_login.go | 16 +- d_api_logout.go | 12 +- d_api_reset_password.go | 162 +++++++++- d_api_signup.go | 78 +++++ forgot_password_handler.go | 40 ++- forgot_password_handler_test.go | 4 +- global.go | 38 ++- login_handler.go | 2 +- openapi.go | 190 ++++++++++-- openapi/auth_paths.go | 449 ++++++++++++++++++++++++++++ openapi/generate_schema.go | 26 +- openapi/schema_object.go | 3 +- password_reset_handler.go | 9 + templates/uadmin/default/trail.html | 5 +- user.go | 15 +- 19 files changed, 1034 insertions(+), 75 deletions(-) create mode 100644 openapi/auth_paths.go diff --git a/auth.go b/auth.go index 8388c199..ed958d31 100644 --- a/auth.go +++ b/auth.go @@ -228,7 +228,7 @@ func getSessionFromRequest(r *http.Request) *Session { key := getSession(r) s := getSessionByKey(key) - if s.ID != 0 { + if s != nil && s.ID != 0 { return s } return nil @@ -734,6 +734,12 @@ func GetRemoteIP(r *http.Request) string { return r.RemoteAddr } +func verifyPassword(hash string, plain string) error { + password := []byte(plain + Salt) + hashedPassword := []byte(hash) + return bcrypt.CompareHashAndPassword(hashedPassword, password) +} + // sanitizeFileName is a function to sanitize file names to pretect // from path traversal attacks using ../ func sanitizeFileName(v string) string { diff --git a/d_api.go b/d_api.go index d7b9883d..3ab72570 100644 --- a/d_api.go +++ b/d_api.go @@ -117,7 +117,6 @@ func dAPIHandler(w http.ResponseWriter, r *http.Request, s *Session) { r.URL.Path = strings.TrimPrefix(r.URL.Path, "/") urlParts := strings.Split(r.URL.Path, "/") - Trail(DEBUG, "%#v", urlParts) ctx := context.WithValue(r.Context(), CKey("dAPI"), true) r = r.WithContext(ctx) diff --git a/d_api_auth.go b/d_api_auth.go index 16ebbdd0..6481486c 100644 --- a/d_api_auth.go +++ b/d_api_auth.go @@ -12,8 +12,9 @@ func dAPIAuthHandler(w http.ResponseWriter, r *http.Request, s *Session) { "status": "error", "err_msg": "dAPI auth is disabled", }) - + return } + // Trim leading path r.URL.Path = strings.TrimPrefix(r.URL.Path, "auth") r.URL.Path = strings.TrimPrefix(r.URL.Path, "/") diff --git a/d_api_change_password.go b/d_api_change_password.go index c4d00b20..19a07171 100644 --- a/d_api_change_password.go +++ b/d_api_change_password.go @@ -3,5 +3,53 @@ package uadmin import "net/http" func dAPIChangePasswordHandler(w http.ResponseWriter, r *http.Request, s *Session) { + if s == nil { + w.WriteHeader(http.StatusForbidden) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "", + }) + return + } + if CheckCSRF(r) { + w.WriteHeader(http.StatusUnauthorized) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Missing CSRF token", + }) + return + } + + oldPassword := r.FormValue("old_password") + newPassword := r.FormValue("new_password") + + // Check if there is a new password + if newPassword == "" { + w.WriteHeader(http.StatusBadRequest) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Missing new password", + }) + return + } + + // Verify old password + err := verifyPassword(s.User.Password, oldPassword) + if err != nil { + incrementInvalidLogins(r) + w.WriteHeader(401) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "invalid password", + }) + return + } + + s.User.Password = newPassword + s.User.Save() + + ReturnJSON(w, r, map[string]interface{}{ + "status": "ok", + }) } diff --git a/d_api_login.go b/d_api_login.go index 19e5704d..551669bb 100644 --- a/d_api_login.go +++ b/d_api_login.go @@ -3,9 +3,7 @@ package uadmin import "net/http" func dAPILoginHandler(w http.ResponseWriter, r *http.Request, s *Session) { - if s != nil { - Logout(r) - } + _ = s // Get request variables username := r.FormValue("username") @@ -17,7 +15,6 @@ func dAPILoginHandler(w http.ResponseWriter, r *http.Request, s *Session) { if otp != "" { // Check if there is username and password or a session key if session != "" { - w.WriteHeader(http.StatusAccepted) s = Login2FAKey(r, session, otp) } else { s = Login2FA(r, username, password, otp) @@ -27,7 +24,7 @@ func dAPILoginHandler(w http.ResponseWriter, r *http.Request, s *Session) { } if optRequired { - w.WriteHeader(http.StatusUnauthorized) + w.WriteHeader(http.StatusAccepted) ReturnJSON(w, r, map[string]interface{}{ "status": "error", "err_msg": "OTP Required", @@ -37,14 +34,17 @@ func dAPILoginHandler(w http.ResponseWriter, r *http.Request, s *Session) { } if s == nil { - w.WriteHeader(http.StatusForbidden) + w.WriteHeader(http.StatusUnauthorized) ReturnJSON(w, r, map[string]interface{}{ "status": "error", - "err_msg": "Invalid username or password", + "err_msg": "Invalid credentials", }) return } + // Preload the user to get the group name + Preload(&s.User) + jwt := SetSessionCookie(w, r, s) ReturnJSON(w, r, map[string]interface{}{ "status": "ok", @@ -54,7 +54,7 @@ func dAPILoginHandler(w http.ResponseWriter, r *http.Request, s *Session) { "username": s.User.Username, "first_name": s.User.FirstName, "last_name": s.User.LastName, - "group_id": s.User.UserGroupID, + "group_name": s.User.UserGroup.GroupName, "admin": s.User.Admin, }, }) diff --git a/d_api_logout.go b/d_api_logout.go index 671206fe..30eb7506 100644 --- a/d_api_logout.go +++ b/d_api_logout.go @@ -7,10 +7,20 @@ func dAPILogoutHandler(w http.ResponseWriter, r *http.Request, s *Session) { w.WriteHeader(http.StatusUnauthorized) ReturnJSON(w, r, map[string]interface{}{ "status": "error", - "err_msg": "Already logged out", + "err_msg": "User not logged in", }) return } + + if CheckCSRF(r) { + w.WriteHeader(http.StatusUnauthorized) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Missing CSRF token", + }) + return + } + Logout(r) ReturnJSON(w, r, map[string]interface{}{ "status": "ok", diff --git a/d_api_reset_password.go b/d_api_reset_password.go index 22034da9..124046dd 100644 --- a/d_api_reset_password.go +++ b/d_api_reset_password.go @@ -1,7 +1,167 @@ package uadmin -import "net/http" +import ( + "net/http" + "time" +) func dAPIResetPasswordHandler(w http.ResponseWriter, r *http.Request, s *Session) { + // Get parameters + username := r.FormValue("username") + email := r.FormValue("email") + otp := r.FormValue("otp") + password := r.FormValue("password") + // check if there is an email or a username + if username == "" && email == "" { + w.WriteHeader(400) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "No username nor email", + }) + // log the request + go func() { + log := &Log{} + if password != "" { + r.Form.Set("password", "*****") + } + log.PasswordReset("", log.Action.PasswordResetDenied(), r) + log.Save() + }() + return + } + + // get user + user := User{} + if username != "" { + Get(&user, "username = ? AND active = ?", username, true) + } else { + Get(&user, "email = ? AND active = ?", email, true) + } + + // log the request + go func() { + log := &Log{} + if password != "" { + r.Form.Set("password", "*****") + } + log.PasswordReset(user.Username, log.Action.PasswordResetRequest(), r) + log.Save() + }() + + // check if the user exists and active + if user.ID == 0 || (user.ExpiresOn != nil || user.ExpiresOn.After(time.Now())) { + w.WriteHeader(404) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "username or email do not match any active user", + }) + // log the request + go func() { + log := &Log{} + if password != "" { + r.Form.Set("password", "*****") + } + log.PasswordReset(user.Username, log.Action.PasswordResetDenied(), r) + log.Save() + }() + return + } + + // If there is no otp, then we assume this is a request to send a password + // reset email + if otp == "" { + err := forgotPasswordHandler(&s.User, r, CustomResetPasswordLink, ResetPasswordMessage) + + if err != nil { + w.WriteHeader(403) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": err.Error(), + }) + // log the request + go func() { + log := &Log{} + if password != "" { + r.Form.Set("password", "*****") + } + log.PasswordReset(user.Username, log.Action.PasswordResetDenied(), r) + log.Save() + }() + return + } + // log the request + w.WriteHeader(http.StatusAccepted) + go func() { + log := &Log{} + if password != "" { + r.Form.Set("password", "*****") + } + r.Form.Set("reset-status", "Email was sent with the OTP") + log.PasswordReset(user.Username, log.Action.PasswordResetSuccessful(), r) + log.Save() + }() + ReturnJSON(w, r, map[string]interface{}{ + "status": "ok", + }) + return + } + + // Since there is an OTP, we can check it and reset the password + // Check if there is a a new password + if password == "" { + w.WriteHeader(400) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "missing password", + }) + // log the request + go func() { + log := &Log{} + if password != "" { + r.Form.Set("password", "*****") + } + log.PasswordReset("", log.Action.PasswordResetDenied(), r) + log.Save() + }() + return + } + + // check OTP + if !user.VerifyOTP(otp) { + incrementInvalidLogins(r) + w.WriteHeader(401) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "invalid or expired OTP", + }) + // log the request + go func() { + log := &Log{} + if password != "" { + r.Form.Set("password", "*****") + } + log.PasswordReset("", log.Action.PasswordResetDenied(), r) + log.Save() + }() + return + } + + // reset the password + user.Password = password + user.Save() + + // log the request + go func() { + log := &Log{} + if password != "" { + r.Form.Set("password", "*****") + } + r.Form.Set("reset-status", "Successfully changed the password") + log.PasswordReset("", log.Action.PasswordResetSuccessful(), r) + log.Save() + }() + ReturnJSON(w, r, map[string]interface{}{ + "status": "ok", + }) } diff --git a/d_api_signup.go b/d_api_signup.go index c1131139..1a03570a 100644 --- a/d_api_signup.go +++ b/d_api_signup.go @@ -3,5 +3,83 @@ package uadmin import "net/http" func dAPISignupHandler(w http.ResponseWriter, r *http.Request, s *Session) { + // Check if signup API is allowed + if !AllowDAPISignup { + w.WriteHeader(http.StatusForbidden) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Signup API is disabled", + }) + return + } + // get variables from request + username := r.FormValue("username") + email := r.FormValue("email") + firstName := r.FormValue("first_name") + lastName := r.FormValue("last_name") + password := r.FormValue("password") + + // set the username to email if there is no username + if username == "" && email != "" { + username = email + } + + // check if password is empty + if password == "" { + w.WriteHeader(http.StatusBadRequest) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "password is empty", + }) + return + } + + // create user object + user := User{ + Username: username, + FirstName: firstName, + LastName: lastName, + Password: password, + Email: email, + Active: DAPISignupActive, + Admin: false, + RemoteAccess: DAPISignupAllowRemote, + UserGroupID: uint(DAPISignupGroupID), + } + + // run custom validation + if SignupValidationHandler != nil { + err := SignupValidationHandler(&user) + w.WriteHeader(http.StatusBadRequest) + if err != nil { + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": err.Error(), + }) + return + } + } + + // Save user record + user.Save() + + // Check if the record was not saved, that means the username is taken + if user.ID == 0 { + w.WriteHeader(400) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "username taken", + }) + } + + // if the user is active, then login in + if user.Active { + dAPILoginHandler(w, r, s) + return + } + + ReturnJSON(w, r, map[string]interface{}{ + "status": "ok", + }) } diff --git a/forgot_password_handler.go b/forgot_password_handler.go index a9a9d314..0428f7d4 100644 --- a/forgot_password_handler.go +++ b/forgot_password_handler.go @@ -8,22 +8,25 @@ import ( ) // forgotPasswordHandler ! -func forgotPasswordHandler(u *User, r *http.Request) error { +func forgotPasswordHandler(u *User, r *http.Request, link string, msg string) error { if u.Email == "" { return fmt.Errorf("unable to reset password, the user does not have an email") } - msg := `Dear {NAME}, + if msg == "" { + msg = `Dear {NAME}, -Have you forgotten your password to access {WEBSITE}. Don't worry we got your back. Please follow the link below to reset your password. - -If you want to reset your password, click this link: -{URL} - -If you didn't request a password reset, you can ignore this message. + Have you forgotten your password to access {WEBSITE}. Don't worry we got your back. Please follow the link below to reset your password. + + If you want to reset your password, click this link: + {URL} + + If you didn't request a password reset, you can ignore this message. + + Regards, + {WEBSITE} Support + ` + } -Regards, -{WEBSITE} Support -` // Check if the host name is in the allowed hosts list allowed := false var host string @@ -46,10 +49,17 @@ Regards, } urlParts := strings.Split(r.Header.Get("origin"), "://") - link := urlParts[0] + "://" + r.Host + RootURL + "resetpassword?u=" + fmt.Sprint(u.ID) + "&key=" + u.GetOTP() - msg = strings.Replace(msg, "{NAME}", u.String(), -1) - msg = strings.Replace(msg, "{WEBSITE}", SiteName, -1) - msg = strings.Replace(msg, "{URL}", link, -1) + if link == "" { + link = "{PROTOCOL}://{HOST}" + RootURL + "resetpassword?u={USER_ID}&key={OTP}" + } + link = strings.ReplaceAll(link, "{PROTOCOL}", urlParts[0]) + link = strings.ReplaceAll(link, "{HOST}", RootURL) + link = strings.ReplaceAll(link, "{USER_ID}", fmt.Sprint(u.ID)) + link = strings.ReplaceAll(link, "{OTP}", u.GetOTP()) + + msg = strings.ReplaceAll(msg, "{NAME}", u.String()) + msg = strings.ReplaceAll(msg, "{WEBSITE}", SiteName) + msg = strings.ReplaceAll(msg, "{URL}", link) subject := "Password reset for " + SiteName err = SendEmail([]string{u.Email}, []string{}, []string{}, subject, msg) diff --git a/forgot_password_handler_test.go b/forgot_password_handler_test.go index 541db364..314401b5 100644 --- a/forgot_password_handler_test.go +++ b/forgot_password_handler_test.go @@ -12,13 +12,13 @@ func (t *UAdminTests) TestForgotPasswordHandler() { user := User{} Get(&user, "id = ?", 1) - err := forgotPasswordHandler(&user, r) + err := forgotPasswordHandler(&user, r, "", "") if err == nil { t.Errorf("forgotPasswordHandler didn't return an error on a user with no email") } user.Email = "user@example.com" - err = forgotPasswordHandler(&user, r) + err = forgotPasswordHandler(&user, r, "", "") if err != nil { t.Errorf("forgotPasswordHandler returned an error. %s", err) } diff --git a/global.go b/global.go index c73bd3e3..e39fa500 100644 --- a/global.go +++ b/global.go @@ -380,7 +380,43 @@ var DisableDAPIAuth = true var AllowDAPISignup = false // DAPISignupGroupID is the default user group id new users get when they sign up -var DAPISignupGroupID = false +// to leave new signed up users without a group, use value 0 for this variable +var DAPISignupGroupID = 0 + +// DAPISignupActive controls if new signed up users are activate automatically +var DAPISignupActive = true + +// DAPISignupAllowRemote controls if new signed up users are can login over the internet +var DAPISignupAllowRemote = true + +// SignupValidationHandler can be used to validate or customize new +// signed up users. Note that the password in the password field +// is passed in plain text. Do not plain text passwords anywhere. +var SignupValidationHandler func(user *User) error + +// CustomResetPasswordLink is the link sent to the user's email to reset their password +// the string may include the following place holder: +// "{PROTOCOL}://{HOST}/resetpassword?u={USER_ID}&key={OTP}" +var CustomResetPasswordLink = "" + +// ResetPasswordMessage is a message that can be sent to the user email when +// a password reset request sends an email. This message may include the +// following place holders: +// {NAME}: user real name +// {WEBSITE}: website name +// {URL}: link to the password reset page +var ResetPasswordMessage = `Dear {NAME}, + +Have you forgotten your password to access {WEBSITE}. Don't worry we got your back. Please follow the link below to reset your password. + +If you want to reset your password, click this link: +{URL} + +If you didn't request a password reset, you can ignore this message. + +Regards, +{WEBSITE} Support +` // Private Global Variables // Regex diff --git a/login_handler.go b/login_handler.go index d83c49e6..19053791 100644 --- a/login_handler.go +++ b/login_handler.go @@ -39,7 +39,7 @@ func loginHandler(w http.ResponseWriter, r *http.Request) { IncrementMetric("uadmin/security/passwordreset/emailsent") c.ErrExists = true c.Err = "Password recovery request sent. Please check email to reset your password" - forgotPasswordHandler(&user, r) + forgotPasswordHandler(&user, r, "", "") } else { IncrementMetric("uadmin/security/passwordreset/invalidemail") c.ErrExists = true diff --git a/openapi.go b/openapi.go index e9ae80f6..1e150097 100644 --- a/openapi.go +++ b/openapi.go @@ -71,7 +71,10 @@ func GenerateOpenAPISchema() { } case cFK: return &openapi.SchemaObject{ - Ref: "#/components/schemas/" + v.Fields[i].TypeName, + AllOf: []*openapi.SchemaObject{ + {Ref: "#/components/schemas/" + v.Fields[i].TypeName}, + {}, + }, } case cHTML: return &openapi.SchemaObject{ @@ -155,14 +158,20 @@ func GenerateOpenAPISchema() { } // Set other schema properties - fields[v.Fields[i].Name].Description = v.Fields[i].Help - fields[v.Fields[i].Name].Default = v.Fields[i].DefaultValue - fields[v.Fields[i].Name].Title = v.Fields[i].DisplayName - if val, ok := v.Fields[i].Max.(string); ok && val != "" { - fields[v.Fields[i].Name].Maximum, _ = strconv.Atoi(val) - } - if val, ok := v.Fields[i].Min.(string); ok && val != "" { - fields[v.Fields[i].Name].Minimum, _ = strconv.Atoi(val) + if v.Fields[i].Type != cFK { + fields[v.Fields[i].Name].Description = v.Fields[i].Help + fields[v.Fields[i].Name].Default = v.Fields[i].DefaultValue + fields[v.Fields[i].Name].Title = v.Fields[i].DisplayName + if val, ok := v.Fields[i].Max.(string); ok && val != "" { + fields[v.Fields[i].Name].Maximum, _ = strconv.Atoi(val) + } + if val, ok := v.Fields[i].Min.(string); ok && val != "" { + fields[v.Fields[i].Name].Minimum, _ = strconv.Atoi(val) + } + } else { + fields[v.Fields[i].Name].AllOf[1].Description = v.Fields[i].Help + fields[v.Fields[i].Name].AllOf[1].Default = v.Fields[i].DefaultValue + fields[v.Fields[i].Name].AllOf[1].Title = v.Fields[i].DisplayName } // Add parameters @@ -332,7 +341,7 @@ func GenerateOpenAPISchema() { Summary: "Read one " + v.DisplayName, Description: "Read one " + v.DisplayName, Get: &openapi.Operation{ - Tags: []string{v.Name, func() string { + Tags: []string{func() string { if v.Category != "" { return v.Category } else { @@ -376,10 +385,10 @@ func GenerateOpenAPISchema() { } // Read Many s.Paths[fmt.Sprintf("/api/d/%s/read", v.ModelName)] = openapi.Path{ - Summary: "Read one " + v.DisplayName, - Description: "Read one " + v.DisplayName, + Summary: "Read many " + v.DisplayName, + Description: "Read many " + v.DisplayName, Get: &openapi.Operation{ - Tags: []string{v.Name, func() string { + Tags: []string{func() string { if v.Category != "" { return v.Category } else { @@ -388,7 +397,7 @@ func GenerateOpenAPISchema() { }()}, Responses: map[string]openapi.Response{ "200": { - Description: v.DisplayName + " record", + Description: v.DisplayName + " records", Content: map[string]openapi.MediaType{ "application/json": { Schema: &openapi.SchemaObject{ @@ -447,7 +456,7 @@ func GenerateOpenAPISchema() { Summary: "Add one " + v.DisplayName, Description: "Add one " + v.DisplayName, Post: &openapi.Operation{ - Tags: []string{v.Name, func() string { + Tags: []string{func() string { if v.Category != "" { return v.Category } else { @@ -456,7 +465,7 @@ func GenerateOpenAPISchema() { }()}, Responses: map[string]openapi.Response{ "200": { - Description: v.DisplayName + " record", + Description: v.DisplayName + " record added", Content: map[string]openapi.MediaType{ "application/json": { Schema: &openapi.SchemaObject{ @@ -475,21 +484,21 @@ func GenerateOpenAPISchema() { }, }, }, - Parameters: append(writeParameters, []openapi.Parameter{ + Parameters: append([]openapi.Parameter{ { Ref: "#/components/parameters/CSRF", }, { Ref: "#/components/parameters/stat", }, - }...), + }, writeParameters...), } - // Add One - s.Paths[fmt.Sprintf("/api/d/%s/add", v.ModelName)] = openapi.Path{ - Summary: "Add one " + v.DisplayName, - Description: "Add one " + v.DisplayName, + // Edit One + s.Paths[fmt.Sprintf("/api/d/%s/edit/{id}", v.ModelName)] = openapi.Path{ + Summary: "Edit one " + v.DisplayName, + Description: "Edit one " + v.DisplayName, Post: &openapi.Operation{ - Tags: []string{v.Name, func() string { + Tags: []string{func() string { if v.Category != "" { return v.Category } else { @@ -498,25 +507,152 @@ func GenerateOpenAPISchema() { }()}, Responses: map[string]openapi.Response{ "200": { - Description: v.DisplayName + " record", + Description: v.DisplayName + " record edited", Content: map[string]openapi.MediaType{ "application/json": { Schema: &openapi.SchemaObject{ - Ref: "#/components/schemas/" + v.Name, + Type: "object", + Properties: map[string]*openapi.SchemaObject{ + "rows_count": {Type: "integer"}, + "status": {Type: "string"}, + }, }, }, }, }, }, }, - Parameters: append(writeParameters, []openapi.Parameter{ + Parameters: append([]openapi.Parameter{ + { + Ref: "#/components/parameters/PathID", + }, { Ref: "#/components/parameters/CSRF", }, { Ref: "#/components/parameters/stat", }, - }...), + }, writeParameters...), + } + // Edit Many + s.Paths[fmt.Sprintf("/api/d/%s/edit", v.ModelName)] = openapi.Path{ + Summary: "Edit many " + v.DisplayName, + Description: "Edit many " + v.DisplayName, + Post: &openapi.Operation{ + Tags: []string{func() string { + if v.Category != "" { + return v.Category + } else { + return "Other" + } + }()}, + Responses: map[string]openapi.Response{ + "200": { + Description: v.DisplayName + " records edited", + Content: map[string]openapi.MediaType{ + "application/json": { + Schema: &openapi.SchemaObject{ + Type: "object", + Properties: map[string]*openapi.SchemaObject{ + "rows_count": {Type: "integer"}, + "status": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Parameters: append([]openapi.Parameter{ + { + Ref: "#/components/parameters/PathID", + }, + { + Ref: "#/components/parameters/CSRF", + }, + { + Ref: "#/components/parameters/stat", + }, + }, append(writeParameters, parameters...)...), + } + // Delete One + s.Paths[fmt.Sprintf("/api/d/%s/delete/{id}", v.ModelName)] = openapi.Path{ + Summary: "Delete one " + v.DisplayName, + Description: "Delete one " + v.DisplayName, + Post: &openapi.Operation{ + Tags: []string{func() string { + if v.Category != "" { + return v.Category + } else { + return "Other" + } + }()}, + Responses: map[string]openapi.Response{ + "200": { + Description: v.DisplayName + " record deleted", + Content: map[string]openapi.MediaType{ + "application/json": { + Schema: &openapi.SchemaObject{ + Type: "object", + Properties: map[string]*openapi.SchemaObject{ + "rows_count": {Type: "integer"}, + "status": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Parameters: []openapi.Parameter{ + { + Ref: "#/components/parameters/PathID", + }, + { + Ref: "#/components/parameters/CSRF", + }, + { + Ref: "#/components/parameters/stat", + }, + }, + } + // Delete Many + s.Paths[fmt.Sprintf("/api/d/%s/delete", v.ModelName)] = openapi.Path{ + Summary: "Delete many " + v.DisplayName, + Description: "Delete many " + v.DisplayName, + Post: &openapi.Operation{ + Tags: []string{func() string { + if v.Category != "" { + return v.Category + } else { + return "Other" + } + }()}, + Responses: map[string]openapi.Response{ + "200": { + Description: v.DisplayName + " records deleted", + Content: map[string]openapi.MediaType{ + "application/json": { + Schema: &openapi.SchemaObject{ + Type: "object", + Properties: map[string]*openapi.SchemaObject{ + "rows_count": {Type: "integer"}, + "status": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Parameters: append([]openapi.Parameter{ + { + Ref: "#/components/parameters/CSRF", + }, + { + Ref: "#/components/parameters/stat", + }, + }, parameters...), } s.Components.Schemas[v.Name] = openapi.SchemaObject{ diff --git a/openapi/auth_paths.go b/openapi/auth_paths.go new file mode 100644 index 00000000..a8c73bee --- /dev/null +++ b/openapi/auth_paths.go @@ -0,0 +1,449 @@ +package openapi + +func getAuthPaths() map[string]Path { + return map[string]Path{ + // Login auth API + "/api/d/auth/login": { + Summary: "Login", + Description: "Login API", + Post: &Operation{ + Tags: []string{"Auth"}, + Responses: map[string]Response{ + "200": { + Description: "Successful login", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "jwt": {Type: "string"}, + "session": {Type: "string"}, + "user": { + Type: "object", + Properties: map[string]*SchemaObject{ + "username": {Type: "string"}, + "admin": {Type: "boolean"}, + "first_name": {Type: "string"}, + "last_name": {Type: "string"}, + "group_name": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + "202": { + Description: "Username and password are correct but MFA is required and OTP was not provided", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + "session": {Type: "string"}, + }, + }, + }, + }, + }, + "401": { + Description: "Invalid credentials", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Parameters: []Parameter{ + { + Name: "username", + In: "query", + Description: "Required for username/password login and single step MFA. But not required during the second step of a two-step MFA authentication", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "password", + In: "query", + Description: "Required for username/password login and single step MFA. But not required during the second step of a two-step MFA authentication", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "otp", + In: "query", + Description: "Not required for username/password login. Required for the second step in a two-step MFA and required single-step for MFA", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "session", + In: "query", + Description: "Only required during the second step of a two-step MFA authentication", + Schema: &SchemaObject{ + Type: "string", + }, + }, + }, + }, + + // Logout auth API + "/api/d/auth/logout": { + Summary: "Logout", + Description: "Logout API", + Post: &Operation{ + Tags: []string{"Auth"}, + Responses: map[string]Response{ + "200": { + Description: "Successful logout", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + }, + }, + }, + }, + }, + "401": { + Description: "User not logged in", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Parameters: []Parameter{ + { + Ref: "#/components/parameters/CSRF", + }, + }, + }, + + // Signup auth API + "/api/d/auth/signup": { + Summary: "Signup", + Description: "Signup API", + Post: &Operation{ + Tags: []string{"Auth"}, + Responses: map[string]Response{ + "200": { + Description: "Successful signup", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "jwt": {Type: "string"}, + "session": {Type: "string"}, + "user": { + Type: "object", + Properties: map[string]*SchemaObject{ + "username": {Type: "string"}, + "admin": {Type: "boolean"}, + "first_name": {Type: "string"}, + "last_name": {Type: "string"}, + "group_name": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + "400": { + Description: "Invalid or missing signup data. More about the error in err_msg.", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Parameters: []Parameter{ + { + Name: "username", + In: "query", + Required: true, + Description: "Username can be any string or an email", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "password", + In: "query", + Required: true, + Description: "Password", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "first_name", + In: "query", + Required: true, + Description: "First name", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "last_name", + In: "query", + Required: true, + Description: "Last name", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "email", + In: "query", + Required: true, + Description: "Email", + Schema: &SchemaObject{ + Type: "string", + }, + }, + }, + }, + + // Reset password auth API + "/api/d/auth/resetpassword": { + Summary: "Reset Password", + Description: "Reset Password API", + Post: &Operation{ + Tags: []string{"Auth"}, + Responses: map[string]Response{ + "200": { + Description: "Successful password reset", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + }, + }, + }, + }, + }, + "202": { + Description: "Password reset email sent", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + }, + }, + }, + }, + }, + "400": { + Description: "Missing password rest data. More about the error in err_msg.", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + "401": { + Description: "Invalid or expired OTP", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + "403": { + Description: "User does not have an email", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + "404": { + Description: "username or email do not match any active user", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Parameters: []Parameter{ + { + Name: "username", + In: "query", + Description: "Username or email is required", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "email", + In: "query", + Required: true, + Description: "Username or email is required", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "password", + In: "query", + Required: true, + Description: "New password which is required in the second step with the OTP", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "otp", + In: "query", + Required: true, + Description: "OTP is required in the second step with the new password", + Schema: &SchemaObject{ + Type: "string", + }, + }, + }, + }, + + // Change password auth API + "/api/d/auth/changepassword": { + Summary: "Change Password", + Description: "Change Password API", + Post: &Operation{ + Tags: []string{"Auth"}, + Responses: map[string]Response{ + "200": { + Description: "Successful password reset", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + }, + }, + }, + }, + }, + "400": { + Description: "Missing new password", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + "401": { + Description: "current password is invalid", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Parameters: []Parameter{ + { + Ref: "#/components/parameters/CSRF", + }, + { + Name: "old_password", + In: "query", + Required: true, + Description: "Current user password", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "new_password", + In: "query", + Required: true, + Description: "New password", + Schema: &SchemaObject{ + Type: "string", + }, + }, + }, + }, + } +} diff --git a/openapi/generate_schema.go b/openapi/generate_schema.go index 65e3e80c..27dc7474 100644 --- a/openapi/generate_schema.go +++ b/openapi/generate_schema.go @@ -2,12 +2,26 @@ package openapi func GenerateBaseSchema() *Schema { s := &Schema{ - OpenAPI: "3.1.0", + OpenAPI: "3.0.3", Info: &SchemaInfo{ Title: " API documentation", Description: "API documentation", Version: "1.0.0", }, + Tags: []Tag{ + { + Name: "Auth", + Description: "Authentication API", + }, + { + Name: "System", + Description: "CRUD APIs for uAdmin core models", + }, + { + Name: "Other", + Description: "CRUD APIs for models with no category", + }, + }, Components: &Components{ Schemas: map[string]SchemaObject{ "Integer": { @@ -19,7 +33,7 @@ func GenerateBaseSchema() *Schema { {Modifier: "__lte", In: "suffix", Summary: "Less than or equal to"}, {Modifier: "__in", In: "suffix", Summary: "Find a value matching any of these values"}, {Modifier: "__between", In: "suffix", Summary: "Selects values within a given range"}, - {Modifier: "!", In: "", Summary: "Negates operator"}, + {Modifier: "!", In: "prefix", Summary: "Negates operator"}, }, XAggregator: []XModifier{ {Modifier: "__sum", In: "suffix", Summary: "Returns the total sum of a numeric field"}, @@ -38,7 +52,7 @@ func GenerateBaseSchema() *Schema { {Modifier: "__lte", In: "suffix", Summary: "Less than or equal to"}, {Modifier: "__in", In: "suffix", Summary: "Find a value matching any of these values"}, {Modifier: "__between", In: "suffix", Summary: "Selects values within a given range"}, - {Modifier: "!", In: "", Summary: "Negates operator"}, + {Modifier: "!", In: "prefix", Summary: "Negates operator"}, }, XAggregator: []XModifier{ {Modifier: "__sum", In: "suffix", Summary: "Returns the total sum of a numeric field"}, @@ -59,7 +73,7 @@ func GenerateBaseSchema() *Schema { {Modifier: "__istartswith", In: "suffix", Summary: "Search for string values that starts with a given substring"}, {Modifier: "__iendswith", In: "", Summary: "Search for string values that ends with a given substring"}, {Modifier: "__in", In: "", Summary: "Find a value matching any of these values"}, - {Modifier: "!", In: "", Summary: "Negates operator"}, + {Modifier: "!", In: "prefix", Summary: "Negates operator"}, }, XAggregator: []XModifier{ {Modifier: "__count", In: "suffix", Summary: "Returns the number of rows"}, @@ -76,7 +90,7 @@ func GenerateBaseSchema() *Schema { {Modifier: "__istartswith", In: "suffix", Summary: "Search for string values that starts with a given substring"}, {Modifier: "__iendswith", In: "", Summary: "Search for string values that ends with a given substring"}, {Modifier: "__in", In: "", Summary: "Find a value matching any of these values"}, - {Modifier: "!", In: "", Summary: "Negates operator"}, + {Modifier: "!", In: "prefix", Summary: "Negates operator"}, }, XAggregator: []XModifier{ {Modifier: "__count", In: "suffix", Summary: "Returns the number of rows"}, @@ -392,7 +406,7 @@ func GenerateBaseSchema() *Schema { }, }, }, - Paths: map[string]Path{}, + Paths: getAuthPaths(), Security: []SecurityRequirement{ { "apiKeyCookie": []string{}, diff --git a/openapi/schema_object.go b/openapi/schema_object.go index acc61dfd..bdae2e6b 100644 --- a/openapi/schema_object.go +++ b/openapi/schema_object.go @@ -20,7 +20,8 @@ type SchemaObject struct { Example *Example `json:"example,omitempty"` Enum []interface{} `json:"enum,omitempty"` OneOf []*SchemaObject `json:"oneOf,omitempty"` - Const interface{} `json:"const,omitempty"` + AllOf []*SchemaObject `json:"allOf,omitempty"` + Const interface{} `json:"x-const,omitempty"` XFilters []XModifier `json:"x-filter,omitempty"` XAggregator []XModifier `json:"x-aggregator,omitempty"` } diff --git a/password_reset_handler.go b/password_reset_handler.go index e6dab260..b6f686d6 100644 --- a/password_reset_handler.go +++ b/password_reset_handler.go @@ -31,6 +31,9 @@ func passwordResetHandler(w http.ResponseWriter, r *http.Request) { if user.ID == 0 { go func() { log := &Log{} + if r.Form.Get("password") != "" { + r.Form.Set("password", "*****") + } r.Form.Set("reset-status", "invalid user id") log.PasswordReset(userID, log.Action.PasswordResetDenied(), r) log.Save() @@ -42,6 +45,9 @@ func passwordResetHandler(w http.ResponseWriter, r *http.Request) { if !user.VerifyOTP(otpCode) { go func() { log := &Log{} + if r.Form.Get("password") != "" { + r.Form.Set("password", "*****") + } r.Form.Set("reset-status", "invalid otp code: "+otpCode) log.PasswordReset(user.Username, log.Action.PasswordResetDenied(), r) log.Save() @@ -61,6 +67,9 @@ func passwordResetHandler(w http.ResponseWriter, r *http.Request) { go func() { log := &Log{} r.Form.Set("reset-status", "Successfully changed the password") + if r.Form.Get("password") != "" { + r.Form.Set("password", "*****") + } log.PasswordReset(user.Username, log.Action.PasswordResetSuccessful(), r) log.Save() }() diff --git a/templates/uadmin/default/trail.html b/templates/uadmin/default/trail.html index a1cfa6a7..02c0830b 100644 --- a/templates/uadmin/default/trail.html +++ b/templates/uadmin/default/trail.html @@ -1,7 +1,7 @@
-Dear {NAME},
Have you forgotten your password to access {WEBSITE}. Don't worry we got your back. Please follow the link below to reset your password. @@ -32,7 +32,7 @@ func forgotPasswordHandler(u *User, r *http.Request, link string, msg string) er var host string var allowedHost string var err error - if host, _, err = net.SplitHostPort(r.Host); err != nil { + if host, _, err = net.SplitHostPort(GetHostName(r)); err != nil { host = r.Host } for _, v := range strings.Split(AllowedHosts, ",") { @@ -41,19 +41,21 @@ func forgotPasswordHandler(u *User, r *http.Request, link string, msg string) er } if allowedHost == host { allowed = true + break } } + host = GetHostName(r) if !allowed { Trail(CRITICAL, "Reset password request for host: (%s) which is not in AllowedHosts settings", host) return nil } - urlParts := strings.Split(r.Header.Get("origin"), "://") + schema := GetSchema(r) if link == "" { - link = "{PROTOCOL}://{HOST}" + RootURL + "resetpassword?u={USER_ID}&key={OTP}" + link = "{SCHEMA}://{HOST}" + RootURL + "resetpassword?u={USER_ID}&key={OTP}" } - link = strings.ReplaceAll(link, "{PROTOCOL}", urlParts[0]) - link = strings.ReplaceAll(link, "{HOST}", RootURL) + link = strings.ReplaceAll(link, "{SCHEMA}", schema) + link = strings.ReplaceAll(link, "{HOST}", host) link = strings.ReplaceAll(link, "{USER_ID}", fmt.Sprint(u.ID)) link = strings.ReplaceAll(link, "{OTP}", u.GetOTP()) @@ -61,6 +63,7 @@ func forgotPasswordHandler(u *User, r *http.Request, link string, msg string) er msg = strings.ReplaceAll(msg, "{WEBSITE}", SiteName) msg = strings.ReplaceAll(msg, "{URL}", link) subject := "Password reset for " + SiteName + err = SendEmail([]string{u.Email}, []string{}, []string{}, subject, msg) return err diff --git a/global.go b/global.go index e39fa500..044e8ce9 100644 --- a/global.go +++ b/global.go @@ -80,7 +80,7 @@ const cEMAIL = "email" const cM2M = "m2m" // Version number as per Semantic Versioning 2.0.0 (semver.org) -const Version = "0.9.1" +const Version = "0.9.2" // VersionCodeName is the cool name we give to versions with significant changes. // This name should always be a bug's name starting from A-Z them revolving back. @@ -396,7 +396,7 @@ var SignupValidationHandler func(user *User) error // CustomResetPasswordLink is the link sent to the user's email to reset their password // the string may include the following place holder: -// "{PROTOCOL}://{HOST}/resetpassword?u={USER_ID}&key={OTP}" +// "{SCHEMA}://{HOST}/resetpassword?u={USER_ID}&key={OTP}" var CustomResetPasswordLink = "" // ResetPasswordMessage is a message that can be sent to the user email when diff --git a/openapi/auth_paths.go b/openapi/auth_paths.go index a8c73bee..f3e49ca5 100644 --- a/openapi/auth_paths.go +++ b/openapi/auth_paths.go @@ -334,9 +334,9 @@ func getAuthPaths() map[string]Path { }, Parameters: []Parameter{ { - Name: "username", + Name: "uid", In: "query", - Description: "Username or email is required", + Description: "Email or uid is required", Schema: &SchemaObject{ Type: "string", }, @@ -344,8 +344,7 @@ func getAuthPaths() map[string]Path { { Name: "email", In: "query", - Required: true, - Description: "Username or email is required", + Description: "Email or uid is required", Schema: &SchemaObject{ Type: "string", }, @@ -353,7 +352,6 @@ func getAuthPaths() map[string]Path { { Name: "password", In: "query", - Required: true, Description: "New password which is required in the second step with the OTP", Schema: &SchemaObject{ Type: "string", @@ -362,8 +360,7 @@ func getAuthPaths() map[string]Path { { Name: "otp", In: "query", - Required: true, - Description: "OTP is required in the second step with the new password", + Description: "OTP is required in the second step with a new password", Schema: &SchemaObject{ Type: "string", }, diff --git a/send_email.go b/send_email.go index 5d9bbd2b..d105498e 100644 --- a/send_email.go +++ b/send_email.go @@ -30,7 +30,8 @@ func SendEmail(to, cc, bcc []string, subject, body string, attachments ...string // prepare body by splitting it into lines of length 73 followed by = body = strings.ReplaceAll(body, "\n", "