diff options
author | T <t@tjp.lol> | 2025-06-26 11:42:17 -0600 |
---|---|---|
committer | T <t@tjp.lol> | 2025-07-01 17:50:49 -0600 |
commit | 639ad6a02cbb4b713434671ec09f309aa5410921 (patch) | |
tree | 7dde9cce8136636d11f2f7c961072984cfc705e7 /magic_link_login.go |
Create authentic_kate: user authentication for go HTTP applications
Diffstat (limited to 'magic_link_login.go')
-rw-r--r-- | magic_link_login.go | 233 |
1 files changed, 233 insertions, 0 deletions
diff --git a/magic_link_login.go b/magic_link_login.go new file mode 100644 index 0000000..9228a97 --- /dev/null +++ b/magic_link_login.go @@ -0,0 +1,233 @@ +package kate + +import ( + "errors" + "net/http" + "strings" + "time" +) + +// MagicLinkConfig configures the magic link authentication handler behavior. +type MagicLinkConfig[T any] struct { + // Mailer provides user data lookup and email sending + Mailer MagicLinkMailer[T] + + // Redirects configures post-authentication redirect behavior + Redirects Redirects + + // UsernameField is the form field name for username + UsernameField string + + // TokenField is the URL parameter name for the magic link token + TokenField string + + // TokenLocation specifies where to retrieve the token from + TokenLocation TokenLocation + + // TokenExpiry is how long the magic link token is valid + TokenExpiry time.Duration + + // LogError is an optional function to log errors + LogError func(error) +} + +// MagicLinkMailer provides user data lookup and email sending for magic link authentication. +type MagicLinkMailer[T any] interface { + // Fetch retrieves user data by username. + // + // Returns the user data, whether the user was found, and any error. + // If the user is not found, should return (zero value, false, nil). + Fetch(username string) (T, bool, error) + + // SendEmail sends a magic link email to the user. + // + // The token parameter contains the encrypted magic link token that should + // be included in the email URL for authentication. + SendEmail(userData T, token string) error +} + +// TokenLocation specifies where the magic link token should be retrieved from +type TokenLocation struct{ location int } + +var ( + // TokenLocationQuery retrieves the token from URL query parameters + TokenLocationQuery = TokenLocation{0} + // TokenLocationPath retrieves the token from URL path parameters using Request.PathValue() + TokenLocationPath = TokenLocation{1} +) + +func (mlc *MagicLinkConfig[T]) setDefaults() { + if mlc.UsernameField == "" { + mlc.UsernameField = "email" + } + if mlc.TokenField == "" { + mlc.TokenField = "token" + } + // TokenLocation defaults to TokenLocationQuery (zero value) + if mlc.TokenExpiry == 0 { + mlc.TokenExpiry = 15 * time.Minute + } +} + +func (mlc MagicLinkConfig[T]) logError(err error) { + if mlc.LogError != nil { + mlc.LogError(err) + } +} + +type magicLinkToken struct { + Username string + Redirect string + ExpiresAt time.Time +} + +func (t magicLinkToken) serialize() string { + return t.Redirect + "\x00" + t.ExpiresAt.Format(time.RFC3339) + "\x00" + t.Username +} + +func parseMagicLinkToken(data string) (magicLinkToken, error) { + parts := strings.SplitN(data, "\x00", 3) + if len(parts) != 3 { + return magicLinkToken{}, errors.New("invalid token format") + } + + redirect := parts[0] + expiresStr := parts[1] + username := parts[2] + + expiresAt, err := time.Parse(time.RFC3339, expiresStr) + if err != nil { + return magicLinkToken{}, err + } + + return magicLinkToken{ + Redirect: redirect, + ExpiresAt: expiresAt, + Username: username, + }, nil +} + +// MagicLinkLoginHandler returns an HTTP handler that processes magic link requests. +// +// It looks up the user, generates a token, and sends an email with the magic link. +func (a Auth[T]) MagicLinkLoginHandler(config MagicLinkConfig[T]) http.Handler { + config.setDefaults() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "Invalid form data", http.StatusBadRequest) + return + } + + username := r.PostForm.Get(config.UsernameField) + + if username == "" { + http.Error(w, config.UsernameField+" required", http.StatusBadRequest) + return + } + + userData, ok, err := config.Mailer.Fetch(username) + if err != nil { + config.logError(err) + http.Error(w, "Error finding user", http.StatusInternalServerError) + return + } + if !ok { + // Don't reveal whether user exists + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("Magic link sent")); err != nil { + config.logError(err) + } + return + } + + tokenData := []byte(magicLinkToken{ + Username: username, + Redirect: config.Redirects.target(r), + ExpiresAt: time.Now().Add(config.TokenExpiry), + }.serialize()) + + encryptedToken := a.enc.Encrypt(tokenData) + + if err := config.Mailer.SendEmail(userData, encryptedToken); err != nil { + config.logError(err) + http.Error(w, "Failed to send email", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("Magic link sent")); err != nil { + config.logError(err) + } + }) +} + +// MagicLinkVerifyHandler returns an HTTP handler that verifies magic link tokens. +// +// It decrypts and validates the token, sets the authentication cookie, and redirects. +func (a Auth[T]) MagicLinkVerifyHandler(config MagicLinkConfig[T]) http.Handler { + config.setDefaults() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var encryptedToken string + switch config.TokenLocation { + case TokenLocationPath: + encryptedToken = r.PathValue(config.TokenField) + case TokenLocationQuery: + encryptedToken = r.URL.Query().Get(config.TokenField) + default: + encryptedToken = r.URL.Query().Get(config.TokenField) + } + + if encryptedToken == "" { + http.Error(w, "Missing token", http.StatusBadRequest) + return + } + + tokenData, ok := a.enc.Decrypt(encryptedToken) + if !ok { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + token, err := parseMagicLinkToken(string(tokenData)) + if err != nil { + config.logError(err) + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + if time.Now().After(token.ExpiresAt) { + http.Error(w, "Token expired", http.StatusUnauthorized) + return + } + + userData, ok, err := config.Mailer.Fetch(token.Username) + if err != nil { + config.logError(err) + http.Error(w, "Authentication failed", http.StatusUnauthorized) + return + } + if !ok { + http.Error(w, "Authentication failed", http.StatusUnauthorized) + return + } + + if err := a.Set(w, userData); err != nil { + config.logError(err) + http.Error(w, "Failed to set authentication", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, token.Redirect, http.StatusSeeOther) + }) +} + |