mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 12:50:05 +01:00
initial rebuild
This commit is contained in:
541
internal/handlers/authn/auth.go
Normal file
541
internal/handlers/authn/auth.go
Normal file
@@ -0,0 +1,541 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/config"
|
||||
"github.com/glueops/autoglue/internal/db"
|
||||
"github.com/glueops/autoglue/internal/db/models"
|
||||
"github.com/glueops/autoglue/internal/middleware"
|
||||
"github.com/glueops/autoglue/internal/response"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Register godoc
|
||||
// @Summary Register a new user
|
||||
// @Description Registers a new user and stores credentials
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body RegisterInput true "User registration input"
|
||||
// @Success 201 {string} string "created"
|
||||
// @Failure 400 {string} string "bad request"
|
||||
// @Router /api/v1/auth/register [post]
|
||||
func Register(w http.ResponseWriter, r *http.Request) {
|
||||
var input RegisterInput
|
||||
json.NewDecoder(r.Body).Decode(&input)
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to hash password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user := models.User{Email: input.Email, Password: string(hashed), Name: input.Name, Role: "user"}
|
||||
if err := db.DB.Create(&user).Error; err != nil {
|
||||
http.Error(w, "registration failed", 400)
|
||||
return
|
||||
}
|
||||
|
||||
_ = response.JSON(w, http.StatusCreated, map[string]string{"status": "created"})
|
||||
}
|
||||
|
||||
// Login godoc
|
||||
// @Summary Authenticate and return a token
|
||||
// @Description Authenticates a user and returns a JWT bearer token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body LoginInput true "User login input"
|
||||
// @Success 200 {object} map[string]string "token"
|
||||
// @Failure 401 {string} string "unauthorized"
|
||||
// @Router /api/v1/auth/login [post]
|
||||
func Login(w http.ResponseWriter, r *http.Request) {
|
||||
var input LoginInput
|
||||
json.NewDecoder(r.Body).Decode(&input)
|
||||
|
||||
var user models.User
|
||||
if err := db.DB.Where("email = ?", input.Email).First(&user).Error; err != nil {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Password)); err != nil {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": user.ID,
|
||||
"exp": time.Now().Add(time.Hour * 72).Unix(),
|
||||
}
|
||||
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
accessStr, _ := accessToken.SignedString(jwtSecret)
|
||||
|
||||
refreshTokenStr := uuid.NewString()
|
||||
|
||||
_ = db.DB.Create(&models.RefreshToken{
|
||||
UserID: user.ID,
|
||||
Token: refreshTokenStr,
|
||||
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||
Revoked: false,
|
||||
}).Error
|
||||
|
||||
_ = response.JSON(w, http.StatusOK, map[string]string{
|
||||
"access_token": accessStr,
|
||||
"refresh_token": refreshTokenStr,
|
||||
})
|
||||
}
|
||||
|
||||
// Refresh godoc
|
||||
// @Summary Refresh access token
|
||||
// @Description Use a refresh token to obtain a new access token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body map[string]string true "refresh_token"
|
||||
// @Success 200 {object} map[string]string "new access token"
|
||||
// @Failure 401 {string} string "unauthorized"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/refresh [post]
|
||||
func Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&input)
|
||||
|
||||
var token models.RefreshToken
|
||||
if err := db.DB.Where("token = ? AND revoked = false", input.RefreshToken).First(&token).Error; err != nil || token.ExpiresAt.Before(time.Now()) {
|
||||
http.Error(w, "invalid or expired refresh token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": token.UserID,
|
||||
"exp": time.Now().Add(rotatedAccessTTL).Unix(),
|
||||
}
|
||||
newAccess := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
newToken, _ := newAccess.SignedString(jwtSecret)
|
||||
|
||||
_ = response.JSON(w, http.StatusOK, map[string]string{
|
||||
"access_token": newToken,
|
||||
})
|
||||
}
|
||||
|
||||
// Logout godoc
|
||||
// @Summary Logout user
|
||||
// @Description Revoke a refresh token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body map[string]string true "refresh_token"
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/logout [post]
|
||||
func Logout(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil || input.RefreshToken == "" {
|
||||
response.Error(w, http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
|
||||
db.DB.Model(&models.RefreshToken{}).Where("token = ?", input.RefreshToken).Update("revoked", true)
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// Me godoc
|
||||
// @Summary Get authenticated user info
|
||||
// @Description Returns the authenticated user's profile and auth context
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} MeResponse
|
||||
// @Failure 401 {string} string "unauthorized"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/me [get]
|
||||
func Me(w http.ResponseWriter, r *http.Request) {
|
||||
authCtx := middleware.GetAuthContext(r)
|
||||
if authCtx == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.DB.First(&user, "id = ?", authCtx.UserID).Error; err != nil {
|
||||
response.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
out := MeResponse{
|
||||
User: UserDTO{
|
||||
ID: user.ID,
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Role: user.Role,
|
||||
CreatedAt: user.CreatedAt, // from Timestamped
|
||||
UpdatedAt: user.UpdatedAt, // from Timestamped
|
||||
},
|
||||
OrgRole: authCtx.OrgRole,
|
||||
}
|
||||
|
||||
if authCtx.OrganizationID != uuid.Nil {
|
||||
s := authCtx.OrganizationID.String()
|
||||
out.OrganizationID = &s
|
||||
}
|
||||
|
||||
if c := authCtx.Claims; c != nil {
|
||||
var exp, iat, nbf int64
|
||||
if c.ExpiresAt != nil {
|
||||
exp = c.ExpiresAt.Time.Unix()
|
||||
}
|
||||
if c.IssuedAt != nil {
|
||||
iat = c.IssuedAt.Time.Unix()
|
||||
}
|
||||
if c.NotBefore != nil {
|
||||
nbf = c.NotBefore.Time.Unix()
|
||||
}
|
||||
|
||||
out.Claims = &AuthClaimsDTO{
|
||||
Orgs: c.Orgs,
|
||||
Roles: c.Roles,
|
||||
Issuer: c.Issuer,
|
||||
Subject: c.Subject,
|
||||
Audience: []string(c.Audience),
|
||||
ExpiresAt: exp,
|
||||
IssuedAt: iat,
|
||||
NotBefore: nbf,
|
||||
}
|
||||
}
|
||||
|
||||
_ = response.JSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// Introspect godoc
|
||||
// @Summary Introspect a token
|
||||
// @Description Returns whether the token is active and basic metadata
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body map[string]string true "token"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/auth/introspect [post]
|
||||
func Introspect(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Token == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tok, err := jwt.Parse(in.Token, func(t *jwt.Token) (any, error) { return jwtSecret, nil })
|
||||
if err == nil && tok.Valid {
|
||||
claims, _ := tok.Claims.(jwt.MapClaims)
|
||||
_ = response.JSON(w, http.StatusOK, map[string]any{
|
||||
"active": true,
|
||||
"type": "access",
|
||||
"sub": claims["sub"],
|
||||
"exp": claims["exp"],
|
||||
"iat": claims["iat"],
|
||||
"nbf": claims["nbf"],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var rt models.RefreshToken
|
||||
if err := db.DB.Where("token = ? AND revoked = false", in.Token).First(&rt).Error; err == nil && rt.ExpiresAt.After(time.Now()) {
|
||||
_ = response.JSON(w, http.StatusOK, map[string]any{
|
||||
"active": true,
|
||||
"type": "refresh",
|
||||
"sub": rt.UserID,
|
||||
"exp": rt.ExpiresAt.Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_ = response.JSON(w, http.StatusOK, map[string]any{"active": false})
|
||||
}
|
||||
|
||||
// RequestPasswordReset godoc
|
||||
// @Summary Request password reset
|
||||
// @Description Sends a reset token to the user's email address
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce plain
|
||||
// @Param body body map[string]string true "email"
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Router /api/v1/auth/password/forgot [post]
|
||||
func RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Email == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Always return 204 to avoid user enumeration.
|
||||
var user models.User
|
||||
if err := db.DB.Where("email = ?", in.Email).First(&user).Error; err == nil {
|
||||
_ = db.DB.Model(&models.PasswordReset{}).
|
||||
Where("user_id = ? AND used = false AND expires_at > ?", user.ID, time.Now()).
|
||||
Update("used", true).Error
|
||||
|
||||
if tok, err := issuePasswordReset(user.ID, user.Email); err == nil {
|
||||
_ = sendEmail(user.Email, "Password reset", fmt.Sprintf("Your password reset token is: %s", tok))
|
||||
err := sendTemplatedEmail(user.Email, "password_reset.tmpl", PasswordResetData{
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
Token: tok,
|
||||
ResetURL: fmt.Sprintf("%s/auth/reset?token=%s", config.FrontendBaseURL(), tok), // e.g. fmt.Sprintf("%s/reset?token=%s", frontendURL, tok)
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("smtp send error: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// ConfirmPasswordReset godoc
|
||||
// @Summary Confirm password reset
|
||||
// @Description Resets the password using a valid reset token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce plain
|
||||
// @Param body body map[string]string true "token, new_password"
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Failure 400 {string} string "bad request"
|
||||
// @Router /api/v1/auth/password/reset [post]
|
||||
func ConfirmPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Token == "" || in.NewPassword == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var pr models.PasswordReset
|
||||
if err := db.DB.Where("token = ? AND used = false", in.Token).First(&pr).Error; err != nil || pr.ExpiresAt.Before(time.Now()) {
|
||||
response.Error(w, http.StatusBadRequest, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.DB.First(&user, "id = ?", pr.UserID).Error; err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(in.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "failed to hash")
|
||||
return
|
||||
}
|
||||
if err := db.DB.Model(&user).Update("password", string(hashed)).Error; err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
|
||||
_ = db.DB.Model(&models.PasswordReset{}).Where("id = ?", pr.ID).Update("used", true).Error
|
||||
_ = db.DB.Model(&models.RefreshToken{}).Where("user_id = ? AND revoked = false", user.ID).Update("revoked", true).Error
|
||||
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// ChangePassword godoc
|
||||
// @Summary Change password
|
||||
// @Description Changes the password for the authenticated user
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce plain
|
||||
// @Param body body map[string]string true "current_password, new_password"
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Failure 400 {string} string "bad request"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/password/change [post]
|
||||
func ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := middleware.GetAuthContext(r)
|
||||
if ctx == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var in struct {
|
||||
Current string `json:"current_password"`
|
||||
New string `json:"new_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Current == "" || in.New == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.DB.First(&user, "id = ?", ctx.UserID).Error; err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(in.Current)); err != nil {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(in.New), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to hash", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := db.DB.Model(&user).Update("password", string(hashed)).Error; err != nil {
|
||||
http.Error(w, "update failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Optional hardening: revoke all refresh tokens after password change
|
||||
// _ = db.DB.Model(&models.RefreshToken{}).Where("user_id = ?", user.ID).Update("revoked", true)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// VerifyEmail godoc
|
||||
// @Summary Verify email address
|
||||
// @Description Verifies the user's email using a token (often from an emailed link)
|
||||
// @Tags auth
|
||||
// @Produce plain
|
||||
// @Param token query string true "verification token"
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Failure 400 {string} string "bad request"
|
||||
// @Router /api/v1/auth/verify [get]
|
||||
func VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var ev models.EmailVerification
|
||||
if err := db.DB.Where("token = ? AND used = false", token).First(&ev).Error; err != nil || ev.ExpiresAt.Before(time.Now()) {
|
||||
response.Error(w, http.StatusBadRequest, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
|
||||
_ = db.DB.Model(&models.User{}).Where("id = ?", ev.UserID).Updates(map[string]any{
|
||||
"email_verified": true,
|
||||
"email_verified_at": time.Now(),
|
||||
}).Error
|
||||
_ = db.DB.Model(&models.EmailVerification{}).Where("id = ?", ev.ID).Update("used", true).Error
|
||||
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// ResendVerification godoc
|
||||
// @Summary Resend email verification
|
||||
// @Description Sends a new email verification token if needed
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce plain
|
||||
// @Param body body map[string]string true "email"
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Router /api/v1/auth/verify/resend [post]
|
||||
func ResendVerification(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Email == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.DB.Where("email = ?", in.Email).First(&user).Error; err == nil {
|
||||
_ = db.DB.Model(&models.EmailVerification{}).
|
||||
Where("user_id = ? AND used = false AND expires_at > ?", user.ID, time.Now()).
|
||||
Update("used", true).Error
|
||||
|
||||
if tok, err := issueEmailVerification(user.ID, user.Email); err == nil {
|
||||
_ = sendEmail(user.Email, "Verify your account", fmt.Sprintf("Your verification token is: %s", tok))
|
||||
_ = sendTemplatedEmail(user.Email, "verify_account.tmpl", VerifyEmailData{
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
Token: tok,
|
||||
VerificationURL: "", // e.g. fmt.Sprintf("%s/verify?token=%s", frontendURL, tok)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// LogoutAll godoc
|
||||
// @Summary Logout from all sessions
|
||||
// @Description Revokes all active refresh tokens for the authenticated user
|
||||
// @Tags auth
|
||||
// @Produce plain
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/logout_all [post]
|
||||
func LogoutAll(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := middleware.GetAuthContext(r)
|
||||
if ctx == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
db.DB.Model(&models.RefreshToken{}).Where("user_id = ? AND revoked = false", ctx.UserID).Update("revoked", true)
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// RotateRefreshToken godoc
|
||||
// @Summary Rotate refresh token
|
||||
// @Description Exchanges a valid refresh token for a new access and refresh token, revoking the old one
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body map[string]string true "refresh_token"
|
||||
// @Success 200 {object} map[string]string "access_token, refresh_token"
|
||||
// @Failure 401 {string} string "unauthorized"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/refresh/rotate [post]
|
||||
func RotateRefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.RefreshToken == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var old models.RefreshToken
|
||||
if err := db.DB.Where("token = ? AND revoked = false", in.RefreshToken).First(&old).Error; err != nil || old.ExpiresAt.Before(time.Now()) {
|
||||
http.Error(w, "invalid or expired refresh token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
_ = db.DB.Model(&models.RefreshToken{}).Where("id = ?", old.ID).Update("revoked", true)
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": old.UserID,
|
||||
"exp": time.Now().Add(15 * time.Minute).Unix(),
|
||||
}
|
||||
access := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
accessStr, _ := access.SignedString(jwtSecret)
|
||||
|
||||
newRefresh := models.RefreshToken{
|
||||
UserID: old.UserID,
|
||||
Token: uuid.NewString(),
|
||||
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||
Revoked: false,
|
||||
}
|
||||
_ = db.DB.Create(&newRefresh).Error
|
||||
|
||||
_ = response.JSON(w, http.StatusOK, map[string]string{
|
||||
"access_token": accessStr,
|
||||
"refresh_token": newRefresh.Token,
|
||||
})
|
||||
}
|
||||
79
internal/handlers/authn/dto.go
Normal file
79
internal/handlers/authn/dto.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/config"
|
||||
"github.com/glueops/autoglue/internal/db/models"
|
||||
appsmtp "github.com/glueops/autoglue/internal/smtp"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var jwtSecret = []byte(config.GetAuthSecret())
|
||||
var (
|
||||
mailerOnce sync.Once
|
||||
mailer *appsmtp.Mailer
|
||||
mailerErr error
|
||||
)
|
||||
|
||||
const (
|
||||
resetTTL = 1 * time.Hour // password reset token validity
|
||||
verifyTTL = 48 * time.Hour // email verification token validity
|
||||
refreshTTL = 7 * 24 * time.Hour
|
||||
accessTTL = 72 * time.Hour
|
||||
rotatedAccessTTL = 15 * time.Minute
|
||||
)
|
||||
|
||||
type RegisterInput struct {
|
||||
Email string `json:"email" example:"me@here.com"`
|
||||
Name string `json:"name" example:"My Name"`
|
||||
Password string `json:"password" example:"123456"`
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
Email string `json:"email" example:"me@here.com"`
|
||||
Password string `json:"password" example:"123456"`
|
||||
}
|
||||
|
||||
type UserDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Role models.Role `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AuthClaimsDTO struct {
|
||||
Orgs []string `json:"orgs,omitempty"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
Issuer string `json:"iss,omitempty"`
|
||||
Subject string `json:"sub,omitempty"`
|
||||
Audience []string `json:"aud,omitempty"`
|
||||
ExpiresAt int64 `json:"exp,omitempty"`
|
||||
IssuedAt int64 `json:"iat,omitempty"`
|
||||
NotBefore int64 `json:"nbf,omitempty"`
|
||||
}
|
||||
|
||||
type MeResponse struct {
|
||||
User UserDTO `json:"user_id"`
|
||||
OrganizationID *string `json:"organization_id,omitempty"`
|
||||
OrgRole string `json:"org_role,omitempty"`
|
||||
Claims *AuthClaimsDTO `json:"claims,omitempty"`
|
||||
}
|
||||
|
||||
type VerifyEmailData struct {
|
||||
Name string
|
||||
Email string
|
||||
Token string
|
||||
VerificationURL string
|
||||
}
|
||||
|
||||
type PasswordResetData struct {
|
||||
Name string
|
||||
Email string
|
||||
Token string
|
||||
ResetURL string
|
||||
}
|
||||
92
internal/handlers/authn/funcs.go
Normal file
92
internal/handlers/authn/funcs.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/config"
|
||||
"github.com/glueops/autoglue/internal/db"
|
||||
"github.com/glueops/autoglue/internal/db/models"
|
||||
appsmtp "github.com/glueops/autoglue/internal/smtp"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func randomToken(nBytes int) (string, error) {
|
||||
b := make([]byte, nBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func issuePasswordReset(userID uuid.UUID, email string) (string, error) {
|
||||
tok, err := randomToken(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pr := models.PasswordReset{
|
||||
UserID: userID,
|
||||
Token: tok, // consider storing hash in prod
|
||||
ExpiresAt: time.Now().Add(resetTTL),
|
||||
Used: false,
|
||||
}
|
||||
if err := db.DB.Create(&pr).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func issueEmailVerification(userID uuid.UUID, email string) (string, error) {
|
||||
tok, err := randomToken(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ev := models.EmailVerification{
|
||||
ID: uuid.New(),
|
||||
UserID: userID,
|
||||
Token: tok, // consider storing hash in prod
|
||||
ExpiresAt: time.Now().Add(verifyTTL),
|
||||
Used: false,
|
||||
}
|
||||
if err := db.DB.Create(&ev).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func sendEmail(to, subject, body string) error {
|
||||
// integrate with your provider here
|
||||
fmt.Printf("Sending email to: %s\n", to)
|
||||
fmt.Printf("Subject: %s\n", subject)
|
||||
fmt.Printf("Content-Type: text/html; charset=UTF-8\n")
|
||||
fmt.Printf("%s\n", body)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getMailer() (*appsmtp.Mailer, error) {
|
||||
mailerOnce.Do(func() {
|
||||
if !config.SMTPEnabled() {
|
||||
mailerErr = fmt.Errorf("smtp disabled")
|
||||
return
|
||||
}
|
||||
mailer, mailerErr = appsmtp.NewMailer(
|
||||
config.SMTPHost(),
|
||||
config.SMTPPort(),
|
||||
config.SMTPUsername(),
|
||||
config.SMTPPassword(),
|
||||
config.SMTPFrom(),
|
||||
)
|
||||
})
|
||||
return mailer, mailerErr
|
||||
}
|
||||
|
||||
func sendTemplatedEmail(to string, templateFile string, data any) error {
|
||||
m, err := getMailer()
|
||||
if err != nil {
|
||||
// fail soft if smtp is disabled; return nil so API UX isn't blocked
|
||||
return nil
|
||||
}
|
||||
return m.Send(to, data, templateFile)
|
||||
}
|
||||
19
internal/handlers/health/health.go
Normal file
19
internal/handlers/health/health.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/glueops/autoglue/internal/response"
|
||||
)
|
||||
|
||||
// Check HealthCheck godoc
|
||||
// @Summary Basic health check
|
||||
// @Description Returns a 200 if the service is up
|
||||
// @Tags health
|
||||
// @Accept json
|
||||
// @Produce plain
|
||||
// @Success 200 {string} string "ok"
|
||||
// @Router /api/healthz [get]
|
||||
func Check(w http.ResponseWriter, r *http.Request) {
|
||||
_ = response.JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
6
internal/handlers/orgs/dto.go
Normal file
6
internal/handlers/orgs/dto.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package orgs
|
||||
|
||||
type OrgInput struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
90
internal/handlers/orgs/orgs.go
Normal file
90
internal/handlers/orgs/orgs.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package orgs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/glueops/autoglue/internal/db"
|
||||
"github.com/glueops/autoglue/internal/db/models"
|
||||
"github.com/glueops/autoglue/internal/middleware"
|
||||
"github.com/glueops/autoglue/internal/response"
|
||||
)
|
||||
|
||||
// CreateOrganization godoc
|
||||
// @Summary Create a new organization
|
||||
// @Description Creates a new organization and assigns the authenticated user as an admin member
|
||||
// @Tags organizations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Optional organization context (ignored for creation)"
|
||||
// @Param body body OrgInput true "Organization Input"
|
||||
// @Success 200 {object} map[string]string "organization_id"
|
||||
// @Failure 400 {string} string "invalid input"
|
||||
// @Failure 401 {string} string "unauthorized"
|
||||
// @Failure 500 {string} string "internal error"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/orgs [post]
|
||||
func CreateOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
authCtx := middleware.GetAuthContext(r)
|
||||
if authCtx == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
userID := authCtx.UserID
|
||||
|
||||
var input OrgInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil || strings.TrimSpace(input.Name) == "" {
|
||||
http.Error(w, "invalid input", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
org := &models.Organization{
|
||||
Name: input.Name,
|
||||
Slug: input.Slug,
|
||||
}
|
||||
|
||||
if err := db.DB.Create(&org).Error; err != nil {
|
||||
http.Error(w, "could not create org", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
member := models.Member{
|
||||
UserID: userID,
|
||||
OrganizationID: org.ID,
|
||||
Role: "admin",
|
||||
}
|
||||
|
||||
if err := db.DB.Create(&member).Error; err != nil {
|
||||
http.Error(w, "could not add member", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_ = response.JSON(w, http.StatusCreated, org)
|
||||
}
|
||||
|
||||
// ListOrganizations godoc
|
||||
// @Summary List organizations for user
|
||||
// @Tags organizations
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.Organization
|
||||
// @Failure 401 {string} string "unauthorized"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/orgs [get]
|
||||
func ListOrganizations(w http.ResponseWriter, r *http.Request) {
|
||||
auth := middleware.GetAuthContext(r)
|
||||
if auth == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var orgs []models.Organization
|
||||
err := db.DB.Joins("JOIN members m ON m.organization_id = organizations.id").
|
||||
Where("m.user_id = ?", auth.UserID).Where("organizations.deleted_at IS NULL").Find(&orgs).Error
|
||||
if err != nil {
|
||||
http.Error(w, "failed to fetch orgs", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_ = response.JSON(w, http.StatusOK, orgs)
|
||||
}
|
||||
Reference in New Issue
Block a user