-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Add user invite link feature for embedded IdP #5157
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 14 commits
f28b086
c4cf97c
00306ea
d02cc3c
114c362
9c5d62d
8e56427
7ed5887
2229f40
7e41d68
39698e7
32a8f5f
05a895f
ea83e54
3806c33
26d369a
59de054
54ae2a4
2de4c1a
a5dd162
97b9010
e6ec1c0
ab4d1e4
b525382
f28561f
abb5363
c6608bd
d6606d5
607ad52
d43f8af
a664640
0af74f4
e44693a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,333 @@ | ||
| package users | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "errors" | ||
| "io" | ||
| "net/http" | ||
| "strings" | ||
| "time" | ||
| "unicode" | ||
|
|
||
| "github.com/gorilla/mux" | ||
|
|
||
| "github.com/netbirdio/netbird/management/server/account" | ||
| nbcontext "github.com/netbirdio/netbird/management/server/context" | ||
| "github.com/netbirdio/netbird/management/server/http/middleware" | ||
| "github.com/netbirdio/netbird/management/server/types" | ||
| "github.com/netbirdio/netbird/shared/management/http/api" | ||
| "github.com/netbirdio/netbird/shared/management/http/util" | ||
| "github.com/netbirdio/netbird/shared/management/status" | ||
| ) | ||
|
|
||
| const ( | ||
| minPasswordLength = 8 | ||
| ) | ||
|
|
||
| // inviteAcceptRateLimiter limits accept invite requests by IP address to prevent brute-force attacks | ||
| var inviteAcceptRateLimiter = middleware.NewAPIRateLimiter(&middleware.RateLimiterConfig{ | ||
|
Check failure on line 28 in management/server/http/handlers/users/invites_handler.go
|
||
| RequestsPerMinute: 10, // 10 attempts per minute per IP | ||
| Burst: 5, // Allow burst of 5 requests | ||
| CleanupInterval: 10 * time.Minute, | ||
| LimiterTTL: 30 * time.Minute, | ||
| }) | ||
|
|
||
| // invitesHandler handles user invite operations | ||
| type invitesHandler struct { | ||
| accountManager account.Manager | ||
| } | ||
|
|
||
| // validatePassword checks password strength requirements: | ||
| // - Minimum 8 characters | ||
| // - At least 1 digit | ||
| // - At least 1 uppercase letter | ||
| // - At least 1 special character | ||
| func validatePassword(password string) error { | ||
| if len(password) < minPasswordLength { | ||
| return errors.New("password must be at least 8 characters long") | ||
| } | ||
|
|
||
| var hasDigit, hasUpper, hasSpecial bool | ||
| for _, c := range password { | ||
| switch { | ||
| case unicode.IsDigit(c): | ||
| hasDigit = true | ||
| case unicode.IsUpper(c): | ||
| hasUpper = true | ||
| case !unicode.IsLetter(c) && !unicode.IsDigit(c): | ||
| hasSpecial = true | ||
| } | ||
| } | ||
|
|
||
| var missing []string | ||
| if !hasDigit { | ||
| missing = append(missing, "one digit") | ||
| } | ||
| if !hasUpper { | ||
| missing = append(missing, "one uppercase letter") | ||
| } | ||
| if !hasSpecial { | ||
| missing = append(missing, "one special character") | ||
| } | ||
|
|
||
| if len(missing) > 0 { | ||
| return errors.New("password must contain at least " + strings.Join(missing, ", ")) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // AddInvitesEndpoints registers invite-related endpoints | ||
| func AddInvitesEndpoints(accountManager account.Manager, router *mux.Router) { | ||
| h := &invitesHandler{accountManager: accountManager} | ||
|
|
||
| // Authenticated endpoints (require admin) | ||
| router.HandleFunc("/users/invites", h.listInvites).Methods("GET", "OPTIONS") | ||
| router.HandleFunc("/users/invites", h.createInvite).Methods("POST", "OPTIONS") | ||
| router.HandleFunc("/users/invites/{inviteId}", h.deleteInvite).Methods("DELETE", "OPTIONS") | ||
| router.HandleFunc("/users/invites/{inviteId}/regenerate", h.regenerateInvite).Methods("POST", "OPTIONS") | ||
| } | ||
|
|
||
| // AddPublicInvitesEndpoints registers public (unauthenticated) invite endpoints | ||
| func AddPublicInvitesEndpoints(accountManager account.Manager, router *mux.Router) { | ||
| h := &invitesHandler{accountManager: accountManager} | ||
|
|
||
| // Public endpoints (no auth required, protected by token) | ||
| router.HandleFunc("/users/invites/{token}", h.getInviteInfo).Methods("GET", "OPTIONS") | ||
| router.HandleFunc("/users/invites/{token}/accept", h.acceptInvite).Methods("POST", "OPTIONS") | ||
| } | ||
|
|
||
| // listInvites handles GET /api/users/invites | ||
| func (h *invitesHandler) listInvites(w http.ResponseWriter, r *http.Request) { | ||
| if r.Method != http.MethodGet { | ||
| util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) | ||
| return | ||
| } | ||
|
|
||
| userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) | ||
| if err != nil { | ||
| util.WriteError(r.Context(), err, w) | ||
| return | ||
| } | ||
|
|
||
| invites, err := h.accountManager.ListUserInvites(r.Context(), userAuth.AccountId, userAuth.UserId) | ||
| if err != nil { | ||
| util.WriteError(r.Context(), err, w) | ||
| return | ||
| } | ||
|
|
||
| resp := make([]api.UserInviteListItem, 0, len(invites)) | ||
| for _, invite := range invites { | ||
| autoGroups := invite.AutoGroups | ||
| if autoGroups == nil { | ||
| autoGroups = []string{} | ||
| } | ||
| resp = append(resp, api.UserInviteListItem{ | ||
| Id: invite.ID, | ||
| Email: invite.Email, | ||
| Name: invite.Name, | ||
| Role: invite.Role, | ||
| AutoGroups: autoGroups, | ||
| ExpiresAt: invite.ExpiresAt.UTC(), | ||
| CreatedAt: invite.CreatedAt.UTC(), | ||
| Expired: invite.IsExpired(), | ||
| }) | ||
| } | ||
|
|
||
| util.WriteJSONObject(r.Context(), w, resp) | ||
| } | ||
|
|
||
| // createInvite handles POST /api/users/invites | ||
| func (h *invitesHandler) createInvite(w http.ResponseWriter, r *http.Request) { | ||
| if r.Method != http.MethodPost { | ||
| util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) | ||
| return | ||
| } | ||
|
|
||
| userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) | ||
| if err != nil { | ||
| util.WriteError(r.Context(), err, w) | ||
| return | ||
| } | ||
|
|
||
| var req api.UserInviteCreateRequest | ||
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||
| util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) | ||
| return | ||
| } | ||
|
|
||
| invite := &types.UserInfo{ | ||
| Email: req.Email, | ||
| Name: req.Name, | ||
| Role: req.Role, | ||
| AutoGroups: req.AutoGroups, | ||
| } | ||
|
|
||
| expiresIn := 0 | ||
| if req.ExpiresIn != nil { | ||
| expiresIn = *req.ExpiresIn | ||
| } | ||
|
|
||
| result, err := h.accountManager.CreateUserInvite(r.Context(), userAuth.AccountId, userAuth.UserId, invite, expiresIn) | ||
| if err != nil { | ||
| util.WriteError(r.Context(), err, w) | ||
| return | ||
| } | ||
|
|
||
| autoGroups := result.UserInfo.AutoGroups | ||
| if autoGroups == nil { | ||
| autoGroups = []string{} | ||
| } | ||
|
|
||
| expiresAt := result.InviteExpiresAt.UTC() | ||
| util.WriteJSONObject(r.Context(), w, &api.UserInviteCreateResponse{ | ||
|
braginini marked this conversation as resolved.
Outdated
|
||
| Id: result.UserInfo.ID, | ||
| Email: result.UserInfo.Email, | ||
| Name: result.UserInfo.Name, | ||
| Role: result.UserInfo.Role, | ||
| AutoGroups: autoGroups, | ||
| Status: result.UserInfo.Status, | ||
| InviteLink: result.InviteLink, | ||
| InviteExpiresAt: expiresAt, | ||
| }) | ||
| } | ||
|
|
||
| // getInviteInfo handles GET /api/users/invites/{token} | ||
| func (h *invitesHandler) getInviteInfo(w http.ResponseWriter, r *http.Request) { | ||
| if r.Method != http.MethodGet { | ||
| util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) | ||
| return | ||
| } | ||
|
|
||
| vars := mux.Vars(r) | ||
| token := vars["token"] | ||
| if token == "" { | ||
| util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "token is required"), w) | ||
| return | ||
| } | ||
|
|
||
| info, err := h.accountManager.GetUserInviteInfo(r.Context(), token) | ||
| if err != nil { | ||
| util.WriteError(r.Context(), err, w) | ||
| return | ||
| } | ||
|
|
||
| expiresAt := info.ExpiresAt.UTC() | ||
| util.WriteJSONObject(r.Context(), w, &api.UserInviteInfo{ | ||
| Email: info.Email, | ||
| Name: info.Name, | ||
| ExpiresAt: expiresAt, | ||
| Valid: info.Valid, | ||
| InvitedBy: info.InvitedBy, | ||
| }) | ||
| } | ||
|
|
||
| // acceptInvite handles POST /api/users/invites/{token}/accept | ||
| func (h *invitesHandler) acceptInvite(w http.ResponseWriter, r *http.Request) { | ||
| if r.Method != http.MethodPost { | ||
| util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) | ||
| return | ||
| } | ||
|
|
||
| vars := mux.Vars(r) | ||
| token := vars["token"] | ||
| if token == "" { | ||
| util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "token is required"), w) | ||
| return | ||
| } | ||
|
|
||
| var req api.UserInviteAcceptRequest | ||
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||
| util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) | ||
| return | ||
| } | ||
|
|
||
| if err := validatePassword(req.Password); err != nil { | ||
| util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid password: %v", err), w) | ||
| return | ||
| } | ||
|
|
||
| err := h.accountManager.AcceptUserInvite(r.Context(), token, req.Password) | ||
| if err != nil { | ||
| util.WriteError(r.Context(), err, w) | ||
| return | ||
| } | ||
|
braginini marked this conversation as resolved.
|
||
|
|
||
| util.WriteJSONObject(r.Context(), w, &api.UserInviteAcceptResponse{Success: true}) | ||
| } | ||
|
|
||
| // regenerateInvite handles POST /api/users/invites/{inviteId}/regenerate | ||
| func (h *invitesHandler) regenerateInvite(w http.ResponseWriter, r *http.Request) { | ||
| if r.Method != http.MethodPost { | ||
| util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) | ||
| return | ||
| } | ||
|
Comment on lines
+195
to
+198
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OPTIONS is registered but always rejected here. This handler returns 405 for any non‑POST request, so registered OPTIONS requests will fail. Either handle OPTIONS explicitly or remove it from the route. 🐛 Suggested fix (handle OPTIONS explicitly)- if r.Method != http.MethodPost {
+ if r.Method == http.MethodOptions {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ if r.Method != http.MethodPost {
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
return
}🤖 Prompt for AI Agents |
||
|
|
||
| userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) | ||
| if err != nil { | ||
| util.WriteError(r.Context(), err, w) | ||
| return | ||
| } | ||
|
|
||
| vars := mux.Vars(r) | ||
| inviteID := vars["inviteId"] | ||
| if inviteID == "" { | ||
| util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invite ID is required"), w) | ||
| return | ||
| } | ||
|
|
||
| var req api.UserInviteRegenerateRequest | ||
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||
| // Allow empty body (io.EOF) - expiresIn is optional | ||
| if !errors.Is(err, io.EOF) { | ||
| util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "couldn't parse JSON request: %v", err), w) | ||
| return | ||
| } | ||
| } | ||
|
|
||
| expiresIn := 0 | ||
| if req.ExpiresIn != nil { | ||
| expiresIn = *req.ExpiresIn | ||
| } | ||
|
|
||
| result, err := h.accountManager.RegenerateUserInvite(r.Context(), userAuth.AccountId, userAuth.UserId, inviteID, expiresIn) | ||
| if err != nil { | ||
| util.WriteError(r.Context(), err, w) | ||
| return | ||
| } | ||
|
|
||
| expiresAt := result.InviteExpiresAt.UTC() | ||
| util.WriteJSONObject(r.Context(), w, &api.UserInviteRegenerateResponse{ | ||
| InviteLink: result.InviteLink, | ||
| InviteExpiresAt: expiresAt, | ||
| }) | ||
| } | ||
|
|
||
| // deleteInvite handles DELETE /api/users/invites/{inviteId} | ||
| func (h *invitesHandler) deleteInvite(w http.ResponseWriter, r *http.Request) { | ||
| if r.Method != http.MethodDelete { | ||
| util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) | ||
| return | ||
| } | ||
|
|
||
| userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) | ||
| if err != nil { | ||
| util.WriteError(r.Context(), err, w) | ||
| return | ||
| } | ||
|
|
||
| vars := mux.Vars(r) | ||
| inviteID := vars["inviteId"] | ||
| if inviteID == "" { | ||
| util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invite ID is required"), w) | ||
| return | ||
| } | ||
|
|
||
| err = h.accountManager.DeleteUserInvite(r.Context(), userAuth.AccountId, userAuth.UserId, inviteID) | ||
| if err != nil { | ||
| util.WriteError(r.Context(), err, w) | ||
| return | ||
| } | ||
|
|
||
| util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.