-
Notifications
You must be signed in to change notification settings - Fork 78
Feat: Add authentication methods #83
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 9 commits
f68f002
1002dfd
9039940
a909850
89140d3
cc3cc1a
f04b46a
5558a5a
7310246
adaef29
b066032
0ab34c3
109febb
ec83439
db325dc
29a019a
c2caeb8
f4b111a
b60122c
37af3d7
51275b0
c36c5da
77d4939
a70ac2d
488f8c9
9dc0bc1
53d50e2
d3a16cf
361e34f
07d2db6
dd703c9
05716c8
9f2ac4b
6dd21d0
ee3e944
e9cdf2e
496e86f
c4d79c5
aaeacba
13be175
43bc47e
e284e65
9e6e8c5
43932fe
9ab12ad
0eebb99
6599acc
a8e71f3
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 | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,15 +1,17 @@ | ||||||||||||||||||||||||||||||||||
| // Package handler implements HTTP handlers for the API. | ||||||||||||||||||||||||||||||||||
| package handler | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||||
| "errors" | ||||||||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||||||||
| "log/slog" | ||||||||||||||||||||||||||||||||||
| "net/http" | ||||||||||||||||||||||||||||||||||
| "strings" | ||||||||||||||||||||||||||||||||||
| "time" | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| "github.com/Devlaner/devlane/api/internal/auth" | ||||||||||||||||||||||||||||||||||
| "github.com/Devlaner/devlane/api/internal/middleware" | ||||||||||||||||||||||||||||||||||
| "github.com/Devlaner/devlane/api/internal/model" | ||||||||||||||||||||||||||||||||||
| "github.com/Devlaner/devlane/api/internal/queue" | ||||||||||||||||||||||||||||||||||
| "github.com/Devlaner/devlane/api/internal/store" | ||||||||||||||||||||||||||||||||||
| "github.com/gin-gonic/gin" | ||||||||||||||||||||||||||||||||||
| "github.com/google/uuid" | ||||||||||||||||||||||||||||||||||
|
|
@@ -23,6 +25,9 @@ type AuthHandler struct { | |||||||||||||||||||||||||||||||||
| Ws *store.WorkspaceStore | ||||||||||||||||||||||||||||||||||
| NotifPrefs *store.UserNotificationPreferenceStore | ||||||||||||||||||||||||||||||||||
| ApiTokens *store.ApiTokenStore | ||||||||||||||||||||||||||||||||||
| Queue *queue.Publisher | ||||||||||||||||||||||||||||||||||
| AppBaseURL string | ||||||||||||||||||||||||||||||||||
| Log *slog.Logger | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| type SignInRequest struct { | ||||||||||||||||||||||||||||||||||
|
|
@@ -52,6 +57,13 @@ func authBool(v model.JSONMap, key string, defaultVal bool) bool { | |||||||||||||||||||||||||||||||||
| return defaultVal | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| func (h *AuthHandler) log() *slog.Logger { | ||||||||||||||||||||||||||||||||||
| if h.Log != nil { | ||||||||||||||||||||||||||||||||||
| return h.Log | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| return slog.Default() | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // SignIn authenticates with email/password and sets a session cookie. | ||||||||||||||||||||||||||||||||||
| // POST /auth/sign-in/ | ||||||||||||||||||||||||||||||||||
| func (h *AuthHandler) SignIn(c *gin.Context) { | ||||||||||||||||||||||||||||||||||
|
|
@@ -132,11 +144,7 @@ func (h *AuthHandler) SignUp(c *gin.Context) { | |||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||
| if err == auth.ErrEmailTaken { | ||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"}) | ||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| if err == auth.ErrUsernameTaken { | ||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusConflict, gin.H{"error": "Username already taken"}) | ||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusConflict, gin.H{"error": "An account with this email already exists"}) | ||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Sign up failed"}) | ||||||||||||||||||||||||||||||||||
|
|
@@ -146,8 +154,12 @@ func (h *AuthHandler) SignUp(c *gin.Context) { | |||||||||||||||||||||||||||||||||
| now := time.Now() | ||||||||||||||||||||||||||||||||||
| inv.Accepted = true | ||||||||||||||||||||||||||||||||||
| inv.RespondedAt = &now | ||||||||||||||||||||||||||||||||||
| _ = h.Winv.Update(ctx, inv) | ||||||||||||||||||||||||||||||||||
| _ = h.Ws.AddMember(ctx, &model.WorkspaceMember{WorkspaceID: inv.WorkspaceID, MemberID: user.ID, Role: inv.Role}) | ||||||||||||||||||||||||||||||||||
| if err := h.Winv.Update(ctx, inv); err != nil { | ||||||||||||||||||||||||||||||||||
| h.log().Error("failed to mark invite accepted", "error", err, "invite_id", inv.ID) | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| if err := h.Ws.AddMember(ctx, &model.WorkspaceMember{WorkspaceID: inv.WorkspaceID, MemberID: user.ID, Role: inv.Role}); err != nil { | ||||||||||||||||||||||||||||||||||
| h.log().Error("failed to add member after signup", "error", err, "user_id", user.ID) | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| setSessionCookie(c, sessionKey) | ||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusCreated, userResponse(user)) | ||||||||||||||||||||||||||||||||||
|
|
@@ -177,12 +189,12 @@ func (h *AuthHandler) Me(c *gin.Context) { | |||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // UpdateMeRequest is the body for PATCH /api/users/me/ | ||||||||||||||||||||||||||||||||||
| type UpdateMeRequest struct { | ||||||||||||||||||||||||||||||||||
| FirstName *string `json:"first_name"` | ||||||||||||||||||||||||||||||||||
| LastName *string `json:"last_name"` | ||||||||||||||||||||||||||||||||||
| DisplayName *string `json:"display_name"` | ||||||||||||||||||||||||||||||||||
| UserTimezone *string `json:"user_timezone"` | ||||||||||||||||||||||||||||||||||
| Avatar *string `json:"avatar"` | ||||||||||||||||||||||||||||||||||
| CoverImage *string `json:"cover_image"` | ||||||||||||||||||||||||||||||||||
| FirstName *string `json:"first_name" binding:"omitempty,max=255"` | ||||||||||||||||||||||||||||||||||
| LastName *string `json:"last_name" binding:"omitempty,max=255"` | ||||||||||||||||||||||||||||||||||
| DisplayName *string `json:"display_name" binding:"omitempty,max=255"` | ||||||||||||||||||||||||||||||||||
| UserTimezone *string `json:"user_timezone" binding:"omitempty,max=100"` | ||||||||||||||||||||||||||||||||||
| Avatar *string `json:"avatar" binding:"omitempty,max=2048"` | ||||||||||||||||||||||||||||||||||
| CoverImage *string `json:"cover_image" binding:"omitempty,max=2048"` | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // UpdateMe updates the authenticated user's profile (email is not updatable). | ||||||||||||||||||||||||||||||||||
|
|
@@ -398,8 +410,8 @@ func (h *AuthHandler) ListTokens(c *gin.Context) { | |||||||||||||||||||||||||||||||||
| type CreateTokenRequest struct { | ||||||||||||||||||||||||||||||||||
| Label string `json:"label" binding:"required"` | ||||||||||||||||||||||||||||||||||
| Description string `json:"description"` | ||||||||||||||||||||||||||||||||||
| ExpiresIn *string `json:"expires_in"` // e.g. "7d", "30d", "90d", "365d", or empty for never | ||||||||||||||||||||||||||||||||||
| ExpiredAt *string `json:"expired_at"` // ISO date for custom expiry | ||||||||||||||||||||||||||||||||||
| ExpiresIn *string `json:"expires_in"` | ||||||||||||||||||||||||||||||||||
| ExpiredAt *string `json:"expired_at"` | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // CreateToken creates a new API token and returns it once (including secret). | ||||||||||||||||||||||||||||||||||
|
|
@@ -495,6 +507,123 @@ func (h *AuthHandler) RevokeToken(c *gin.Context) { | |||||||||||||||||||||||||||||||||
| c.Status(http.StatusNoContent) | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // InstanceAuthConfig returns public auth configuration (no auth required). | ||||||||||||||||||||||||||||||||||
| // GET /auth/config/ | ||||||||||||||||||||||||||||||||||
| func (h *AuthHandler) InstanceAuthConfig(c *gin.Context) { | ||||||||||||||||||||||||||||||||||
| isPasswordEnabled := true | ||||||||||||||||||||||||||||||||||
| enableSignup := true | ||||||||||||||||||||||||||||||||||
| isSmtpConfigured := false | ||||||||||||||||||||||||||||||||||
| if h.Settings != nil { | ||||||||||||||||||||||||||||||||||
| ctx := c.Request.Context() | ||||||||||||||||||||||||||||||||||
| row, _ := h.Settings.Get(ctx, "auth") | ||||||||||||||||||||||||||||||||||
| if row != nil { | ||||||||||||||||||||||||||||||||||
| isPasswordEnabled = authBool(row.Value, "password", true) | ||||||||||||||||||||||||||||||||||
| enableSignup = authBool(row.Value, "allow_public_signup", true) | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| emailRow, _ := h.Settings.Get(ctx, "email") | ||||||||||||||||||||||||||||||||||
| if emailRow != nil && emailRow.Value != nil { | ||||||||||||||||||||||||||||||||||
| host, _ := emailRow.Value["host"].(string) | ||||||||||||||||||||||||||||||||||
| isSmtpConfigured = strings.TrimSpace(host) != "" | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusOK, gin.H{ | ||||||||||||||||||||||||||||||||||
| "is_email_password_enabled": isPasswordEnabled, | ||||||||||||||||||||||||||||||||||
| "enable_signup": enableSignup, | ||||||||||||||||||||||||||||||||||
| "is_smtp_configured": isSmtpConfigured, | ||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // EmailCheck checks whether an email is already registered. | ||||||||||||||||||||||||||||||||||
| // POST /auth/email-check/ | ||||||||||||||||||||||||||||||||||
| func (h *AuthHandler) EmailCheck(c *gin.Context) { | ||||||||||||||||||||||||||||||||||
| var body struct { | ||||||||||||||||||||||||||||||||||
| Email string `json:"email" binding:"required,email"` | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| if err := c.ShouldBindJSON(&body); err != nil { | ||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) | ||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| exists, err := h.Auth.EmailCheck(c.Request.Context(), body.Email) | ||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Check failed"}) | ||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| allowPublicSignup := true | ||||||||||||||||||||||||||||||||||
| if h.Settings != nil { | ||||||||||||||||||||||||||||||||||
| row, _ := h.Settings.Get(c.Request.Context(), "auth") | ||||||||||||||||||||||||||||||||||
| if row != nil { | ||||||||||||||||||||||||||||||||||
| allowPublicSignup = authBool(row.Value, "allow_public_signup", true) | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusOK, gin.H{ | ||||||||||||||||||||||||||||||||||
| "existing": exists, | ||||||||||||||||||||||||||||||||||
| "status": "CREDENTIAL", | ||||||||||||||||||||||||||||||||||
| "allow_public_signup": allowPublicSignup, | ||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // ForgotPassword initiates a password reset flow by sending an email. | ||||||||||||||||||||||||||||||||||
| // POST /auth/forgot-password/ | ||||||||||||||||||||||||||||||||||
| func (h *AuthHandler) ForgotPassword(c *gin.Context) { | ||||||||||||||||||||||||||||||||||
| var body struct { | ||||||||||||||||||||||||||||||||||
| Email string `json:"email" binding:"required,email"` | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| if err := c.ShouldBindJSON(&body); err != nil { | ||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) | ||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| ctx := c.Request.Context() | ||||||||||||||||||||||||||||||||||
| token, err := h.Auth.ForgotPassword(ctx, body.Email) | ||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||
| h.log().Error("forgot password error", "error", err) | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| if token != "" && h.Queue != nil && h.AppBaseURL != "" { | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
| resetLink := strings.TrimSuffix(h.AppBaseURL, "/") + "/reset-password?token=" + token | ||||||||||||||||||||||||||||||||||
| subject := "Reset your Devlane password" | ||||||||||||||||||||||||||||||||||
| bodyText := fmt.Sprintf( | ||||||||||||||||||||||||||||||||||
| "You requested a password reset.\n\nClick the link below to reset your password:\n%s\n\nThis link expires in 30 minutes. If you did not request a reset, ignore this email.\n", | ||||||||||||||||||||||||||||||||||
| resetLink, | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
| _ = h.Queue.PublishSendEmail(ctx, queue.SendEmailPayload{ | ||||||||||||||||||||||||||||||||||
| To: body.Email, | ||||||||||||||||||||||||||||||||||
| Subject: subject, | ||||||||||||||||||||||||||||||||||
| Body: bodyText, | ||||||||||||||||||||||||||||||||||
| Kind: "forgot_password", | ||||||||||||||||||||||||||||||||||
| Extra: map[string]string{"reset_link": resetLink}, | ||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
| _ = h.Queue.PublishSendEmail(ctx, queue.SendEmailPayload{ | |
| To: body.Email, | |
| Subject: subject, | |
| Body: bodyText, | |
| Kind: "forgot_password", | |
| Extra: map[string]string{"reset_link": resetLink}, | |
| }) | |
| if err := h.Queue.PublishSendEmail(ctx, queue.SendEmailPayload{ | |
| To: body.Email, | |
| Subject: subject, | |
| Body: bodyText, | |
| Kind: "forgot_password", | |
| Extra: map[string]string{"reset_link": resetLink}, | |
| }); err != nil { | |
| h.log().Error("failed to enqueue forgot password email", "error", err, "email", body.Email) | |
| } |
Uh oh!
There was an error while loading. Please reload this page.