CAPTCHAと2FAを実装
This commit is contained in:
@@ -21,6 +21,13 @@ type NotificationConfig struct {
|
||||
TelegramBotToken string
|
||||
}
|
||||
|
||||
type CaptchaConfig struct {
|
||||
Enabled bool
|
||||
Type string // "turnstile" or "image"
|
||||
TurnstileSiteKey string
|
||||
TurnstileSecretKey string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
SessionSecret string
|
||||
@@ -34,6 +41,7 @@ type Config struct {
|
||||
TrustedProxies []string
|
||||
Database DatabaseConfig
|
||||
Notification NotificationConfig
|
||||
Captcha CaptchaConfig
|
||||
}
|
||||
|
||||
func Load(configPath string) *Config {
|
||||
@@ -56,6 +64,10 @@ func Load(configPath string) *Config {
|
||||
Password: "",
|
||||
Name: "homework_manager",
|
||||
},
|
||||
Captcha: CaptchaConfig{
|
||||
Enabled: false,
|
||||
Type: "image",
|
||||
},
|
||||
}
|
||||
|
||||
if configPath == "" {
|
||||
@@ -134,6 +146,21 @@ func Load(configPath string) *Config {
|
||||
if section.HasKey("telegram_bot_token") {
|
||||
cfg.Notification.TelegramBotToken = section.Key("telegram_bot_token").String()
|
||||
}
|
||||
|
||||
// Captcha section
|
||||
section = iniFile.Section("captcha")
|
||||
if section.HasKey("enabled") {
|
||||
cfg.Captcha.Enabled = section.Key("enabled").MustBool(false)
|
||||
}
|
||||
if section.HasKey("type") {
|
||||
cfg.Captcha.Type = section.Key("type").String()
|
||||
}
|
||||
if section.HasKey("turnstile_site_key") {
|
||||
cfg.Captcha.TurnstileSiteKey = section.Key("turnstile_site_key").String()
|
||||
}
|
||||
if section.HasKey("turnstile_secret_key") {
|
||||
cfg.Captcha.TurnstileSecretKey = section.Key("turnstile_secret_key").String()
|
||||
}
|
||||
} else {
|
||||
log.Println("config.ini not found, using environment variables or defaults")
|
||||
}
|
||||
@@ -183,6 +210,18 @@ func Load(configPath string) *Config {
|
||||
if telegramToken := os.Getenv("TELEGRAM_BOT_TOKEN"); telegramToken != "" {
|
||||
cfg.Notification.TelegramBotToken = telegramToken
|
||||
}
|
||||
if captchaEnabled := os.Getenv("CAPTCHA_ENABLED"); captchaEnabled != "" {
|
||||
cfg.Captcha.Enabled = captchaEnabled == "true" || captchaEnabled == "1"
|
||||
}
|
||||
if captchaType := os.Getenv("CAPTCHA_TYPE"); captchaType != "" {
|
||||
cfg.Captcha.Type = captchaType
|
||||
}
|
||||
if turnstileSiteKey := os.Getenv("TURNSTILE_SITE_KEY"); turnstileSiteKey != "" {
|
||||
cfg.Captcha.TurnstileSiteKey = turnstileSiteKey
|
||||
}
|
||||
if turnstileSecretKey := os.Getenv("TURNSTILE_SECRET_KEY"); turnstileSecretKey != "" {
|
||||
cfg.Captcha.TurnstileSecretKey = turnstileSecretKey
|
||||
}
|
||||
|
||||
if cfg.SessionSecret == "" {
|
||||
log.Fatal("FATAL: Session secret is not set. Please set it in config.ini ([session] secret) or via SESSION_SECRET environment variable.")
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"homework-manager/internal/config"
|
||||
"homework-manager/internal/middleware"
|
||||
"homework-manager/internal/service"
|
||||
|
||||
@@ -10,33 +11,96 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const twoFAPendingKey = "2fa_pending_user_id"
|
||||
|
||||
type AuthHandler struct {
|
||||
authService *service.AuthService
|
||||
authService *service.AuthService
|
||||
totpService *service.TOTPService
|
||||
captchaService *service.CaptchaService
|
||||
captchaCfg config.CaptchaConfig
|
||||
}
|
||||
|
||||
func NewAuthHandler() *AuthHandler {
|
||||
func NewAuthHandler(captchaCfg config.CaptchaConfig) *AuthHandler {
|
||||
captchaSvc := service.NewCaptchaService(captchaCfg.Type, captchaCfg.TurnstileSecretKey)
|
||||
return &AuthHandler{
|
||||
authService: service.NewAuthService(),
|
||||
authService: service.NewAuthService(),
|
||||
totpService: service.NewTOTPService(),
|
||||
captchaService: captchaSvc,
|
||||
captchaCfg: captchaCfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) captchaData() gin.H {
|
||||
data := gin.H{
|
||||
"captchaEnabled": h.captchaCfg.Enabled,
|
||||
"captchaType": h.captchaCfg.Type,
|
||||
}
|
||||
if h.captchaCfg.Enabled && h.captchaCfg.Type == "turnstile" {
|
||||
data["turnstileSiteKey"] = h.captchaCfg.TurnstileSiteKey
|
||||
}
|
||||
if h.captchaCfg.Enabled && h.captchaCfg.Type == "image" {
|
||||
data["captchaID"] = h.captchaService.NewImageCaptcha()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (h *AuthHandler) verifyCaptcha(c *gin.Context) bool {
|
||||
if !h.captchaCfg.Enabled {
|
||||
return true
|
||||
}
|
||||
switch h.captchaCfg.Type {
|
||||
case "turnstile":
|
||||
token := c.PostForm("cf-turnstile-response")
|
||||
ok, err := h.captchaService.VerifyTurnstile(token, c.ClientIP())
|
||||
return err == nil && ok
|
||||
case "image":
|
||||
id := c.PostForm("captcha_id")
|
||||
answer := c.PostForm("captcha_answer")
|
||||
return h.captchaService.VerifyImageCaptcha(id, answer)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ShowLogin(c *gin.Context) {
|
||||
RenderHTML(c, http.StatusOK, "login.html", gin.H{
|
||||
"title": "ログイン",
|
||||
})
|
||||
data := gin.H{"title": "ログイン"}
|
||||
for k, v := range h.captchaData() {
|
||||
data[k] = v
|
||||
}
|
||||
RenderHTML(c, http.StatusOK, "login.html", data)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
email := c.PostForm("email")
|
||||
password := c.PostForm("password")
|
||||
|
||||
renderLoginError := func(msg string) {
|
||||
data := gin.H{
|
||||
"title": "ログイン",
|
||||
"error": msg,
|
||||
"email": email,
|
||||
}
|
||||
for k, v := range h.captchaData() {
|
||||
data[k] = v
|
||||
}
|
||||
RenderHTML(c, http.StatusOK, "login.html", data)
|
||||
}
|
||||
|
||||
if !h.verifyCaptcha(c) {
|
||||
renderLoginError("CAPTCHAの検証に失敗しました。もう一度お試しください")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.Login(email, password)
|
||||
if err != nil {
|
||||
RenderHTML(c, http.StatusOK, "login.html", gin.H{
|
||||
"title": "ログイン",
|
||||
"error": "メールアドレスまたはパスワードが正しくありません",
|
||||
"email": email,
|
||||
})
|
||||
renderLoginError("メールアドレスまたはパスワードが正しくありません")
|
||||
return
|
||||
}
|
||||
|
||||
if user.TOTPEnabled {
|
||||
session := sessions.Default(c)
|
||||
session.Set(twoFAPendingKey, user.ID)
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/login/2fa")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -49,35 +113,91 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ShowRegister(c *gin.Context) {
|
||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
||||
"title": "新規登録",
|
||||
func (h *AuthHandler) ShowLogin2FA(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
if session.Get(twoFAPendingKey) == nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
RenderHTML(c, http.StatusOK, "login_2fa.html", gin.H{
|
||||
"title": "2段階認証",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login2FA(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
pendingID := session.Get(twoFAPendingKey)
|
||||
if pendingID == nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
userID := pendingID.(uint)
|
||||
user, err := h.authService.GetUserByID(userID)
|
||||
if err != nil {
|
||||
session.Delete(twoFAPendingKey)
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
code := c.PostForm("totp_code")
|
||||
if !h.totpService.Validate(user.TOTPSecret, code) {
|
||||
RenderHTML(c, http.StatusOK, "login_2fa.html", gin.H{
|
||||
"title": "2段階認証",
|
||||
"error": "認証コードが正しくありません",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
session.Delete(twoFAPendingKey)
|
||||
session.Set(middleware.UserIDKey, user.ID)
|
||||
session.Set(middleware.UserRoleKey, user.Role)
|
||||
session.Set(middleware.UserNameKey, user.Name)
|
||||
session.Save()
|
||||
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ShowRegister(c *gin.Context) {
|
||||
data := gin.H{"title": "新規登録"}
|
||||
for k, v := range h.captchaData() {
|
||||
data[k] = v
|
||||
}
|
||||
RenderHTML(c, http.StatusOK, "register.html", data)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
email := c.PostForm("email")
|
||||
password := c.PostForm("password")
|
||||
passwordConfirm := c.PostForm("password_confirm")
|
||||
name := c.PostForm("name")
|
||||
|
||||
if password != passwordConfirm {
|
||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
||||
renderRegisterError := func(msg string) {
|
||||
data := gin.H{
|
||||
"title": "新規登録",
|
||||
"error": "パスワードが一致しません",
|
||||
"error": msg,
|
||||
"email": email,
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
for k, v := range h.captchaData() {
|
||||
data[k] = v
|
||||
}
|
||||
RenderHTML(c, http.StatusOK, "register.html", data)
|
||||
}
|
||||
|
||||
if !h.verifyCaptcha(c) {
|
||||
renderRegisterError("CAPTCHAの検証に失敗しました。もう一度お試しください")
|
||||
return
|
||||
}
|
||||
|
||||
if password != passwordConfirm {
|
||||
renderRegisterError("パスワードが一致しません")
|
||||
return
|
||||
}
|
||||
|
||||
if len(password) < 8 {
|
||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
||||
"title": "新規登録",
|
||||
"error": "パスワードは8文字以上で入力してください",
|
||||
"email": email,
|
||||
"name": name,
|
||||
})
|
||||
renderRegisterError("パスワードは8文字以上で入力してください")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,12 +207,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
if err == service.ErrEmailAlreadyExists {
|
||||
errorMsg = "このメールアドレスは既に使用されています"
|
||||
}
|
||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
||||
"title": "新規登録",
|
||||
"error": errorMsg,
|
||||
"email": email,
|
||||
"name": name,
|
||||
})
|
||||
renderRegisterError(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -7,18 +7,23 @@ import (
|
||||
"homework-manager/internal/models"
|
||||
"homework-manager/internal/service"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ProfileHandler struct {
|
||||
authService *service.AuthService
|
||||
totpService *service.TOTPService
|
||||
notificationService *service.NotificationService
|
||||
appName string
|
||||
}
|
||||
|
||||
func NewProfileHandler(notificationService *service.NotificationService) *ProfileHandler {
|
||||
return &ProfileHandler{
|
||||
authService: service.NewAuthService(),
|
||||
totpService: service.NewTOTPService(),
|
||||
notificationService: notificationService,
|
||||
appName: "Super-HomeworkManager",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,3 +176,135 @@ func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
}
|
||||
|
||||
const totpPendingSecretKey = "totp_pending_secret"
|
||||
|
||||
func (h *ProfileHandler) ShowTOTPSetup(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
user, _ := h.authService.GetUserByID(userID)
|
||||
|
||||
setupData, err := h.totpService.GenerateSecret(user.Email, h.appName)
|
||||
if err != nil {
|
||||
RenderHTML(c, http.StatusOK, "totp_setup.html", gin.H{
|
||||
"title": "2段階認証の設定",
|
||||
"error": "シークレットの生成に失敗しました",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
session := sessions.Default(c)
|
||||
session.Set(totpPendingSecretKey, setupData.Secret)
|
||||
session.Save()
|
||||
|
||||
RenderHTML(c, http.StatusOK, "totp_setup.html", gin.H{
|
||||
"title": "2段階認証の設定",
|
||||
"secret": setupData.Secret,
|
||||
"qrCode": setupData.QRCodeB64,
|
||||
"otpAuthURL": setupData.OTPAuthURL,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ProfileHandler) EnableTOTP(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
user, _ := h.authService.GetUserByID(userID)
|
||||
|
||||
session := sessions.Default(c)
|
||||
secret, ok := session.Get(totpPendingSecretKey).(string)
|
||||
if !ok || secret == "" {
|
||||
c.Redirect(http.StatusFound, "/profile/totp/setup")
|
||||
return
|
||||
}
|
||||
|
||||
renderSetupError := func(msg string) {
|
||||
data := gin.H{
|
||||
"title": "2段階認証の設定",
|
||||
"error": msg,
|
||||
"secret": secret,
|
||||
}
|
||||
if setupData, err := h.totpService.SetupDataFromSecret(secret, user.Email, h.appName); err == nil {
|
||||
data["qrCode"] = setupData.QRCodeB64
|
||||
data["otpAuthURL"] = setupData.OTPAuthURL
|
||||
}
|
||||
RenderHTML(c, http.StatusOK, "totp_setup.html", data)
|
||||
}
|
||||
|
||||
password := c.PostForm("password")
|
||||
if _, err := h.authService.Login(user.Email, password); err != nil {
|
||||
renderSetupError("パスワードが正しくありません")
|
||||
return
|
||||
}
|
||||
|
||||
code := c.PostForm("totp_code")
|
||||
if !h.totpService.Validate(secret, code) {
|
||||
renderSetupError("認証コードが正しくありません。もう一度試してください")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.authService.EnableTOTP(userID, secret); err != nil {
|
||||
RenderHTML(c, http.StatusOK, "totp_setup.html", gin.H{
|
||||
"title": "2段階認証の設定",
|
||||
"error": "2段階認証の有効化に失敗しました",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
session.Delete(totpPendingSecretKey)
|
||||
session.Save()
|
||||
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
notifySettings, _ := h.notificationService.GetUserSettings(userID)
|
||||
user, _ = h.authService.GetUserByID(userID)
|
||||
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"totpSuccess": "2段階認証を有効化しました",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ProfileHandler) DisableTOTP(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
user, _ := h.authService.GetUserByID(userID)
|
||||
notifySettings, _ := h.notificationService.GetUserSettings(userID)
|
||||
|
||||
password := c.PostForm("password")
|
||||
if _, err := h.authService.Login(user.Email, password); err != nil {
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"totpError": "パスワードが正しくありません",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.authService.DisableTOTP(userID); err != nil {
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"totpError": "2段階認証の無効化に失敗しました",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, _ = h.authService.GetUserByID(userID)
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"totpSuccess": "2段階認証を無効化しました",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,21 +7,33 @@ import (
|
||||
)
|
||||
|
||||
type SecurityConfig struct {
|
||||
HTTPS bool
|
||||
HTTPS bool
|
||||
TurnstileEnabled bool
|
||||
}
|
||||
|
||||
func SecurityHeaders(config SecurityConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if config.HTTPS {
|
||||
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
|
||||
scriptSrc := "'self' 'unsafe-inline' https://cdn.jsdelivr.net"
|
||||
frameSrc := "'none'"
|
||||
connectSrc := "'self'"
|
||||
if config.TurnstileEnabled {
|
||||
scriptSrc += " https://challenges.cloudflare.com"
|
||||
frameSrc = "https://challenges.cloudflare.com"
|
||||
connectSrc += " https://challenges.cloudflare.com"
|
||||
}
|
||||
|
||||
csp := []string{
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
||||
"script-src " + scriptSrc,
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
||||
"font-src 'self' https://cdn.jsdelivr.net",
|
||||
"img-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"connect-src " + connectSrc,
|
||||
"frame-src " + frameSrc,
|
||||
"frame-ancestors 'none'",
|
||||
}
|
||||
c.Header("Content-Security-Policy", strings.Join(csp, "; "))
|
||||
|
||||
@@ -12,6 +12,8 @@ type User struct {
|
||||
PasswordHash string `gorm:"not null" json:"-"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Role string `gorm:"not null;default:user" json:"role"` // "admin" or "user"
|
||||
TOTPSecret string `gorm:"size:100" json:"-"`
|
||||
TOTPEnabled bool `gorm:"default:false" json:"totp_enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"homework-manager/internal/middleware"
|
||||
"homework-manager/internal/service"
|
||||
|
||||
"github.com/dchest/captcha"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -175,7 +176,8 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
r.Use(middleware.RequestTimer())
|
||||
|
||||
securityConfig := middleware.SecurityConfig{
|
||||
HTTPS: cfg.HTTPS,
|
||||
HTTPS: cfg.HTTPS,
|
||||
TurnstileEnabled: cfg.Captcha.Enabled && cfg.Captcha.Type == "turnstile",
|
||||
}
|
||||
r.Use(middleware.SecurityHeaders(securityConfig))
|
||||
r.Use(middleware.ForceHTTPS(securityConfig))
|
||||
@@ -196,13 +198,22 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
|
||||
notificationService.StartReminderScheduler()
|
||||
|
||||
authHandler := handler.NewAuthHandler()
|
||||
authHandler := handler.NewAuthHandler(cfg.Captcha)
|
||||
assignmentHandler := handler.NewAssignmentHandler(notificationService)
|
||||
adminHandler := handler.NewAdminHandler()
|
||||
profileHandler := handler.NewProfileHandler(notificationService)
|
||||
apiHandler := handler.NewAPIHandler()
|
||||
apiRecurringHandler := handler.NewAPIRecurringHandler()
|
||||
|
||||
r.GET("/captcha/:file", gin.WrapH(captcha.Server(captcha.StdWidth, captcha.StdHeight)))
|
||||
r.GET("/captcha-new", func(c *gin.Context) {
|
||||
id := captcha.New()
|
||||
c.String(http.StatusOK, id)
|
||||
})
|
||||
|
||||
r.GET("/login/2fa", authHandler.ShowLogin2FA)
|
||||
r.POST("/login/2fa", csrfMiddleware, authHandler.Login2FA)
|
||||
|
||||
guest := r.Group("/")
|
||||
guest.Use(middleware.GuestOnly())
|
||||
guest.Use(csrfMiddleware)
|
||||
@@ -252,6 +263,9 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
auth.POST("/profile", profileHandler.Update)
|
||||
auth.POST("/profile/password", profileHandler.ChangePassword)
|
||||
auth.POST("/profile/notifications", profileHandler.UpdateNotificationSettings)
|
||||
auth.GET("/profile/totp/setup", profileHandler.ShowTOTPSetup)
|
||||
auth.POST("/profile/totp/setup", profileHandler.EnableTOTP)
|
||||
auth.POST("/profile/totp/disable", profileHandler.DisableTOTP)
|
||||
|
||||
admin := auth.Group("/admin")
|
||||
admin.Use(middleware.AdminRequired())
|
||||
|
||||
@@ -104,3 +104,23 @@ func (s *AuthService) UpdateProfile(userID uint, name string) error {
|
||||
user.Name = name
|
||||
return s.userRepo.Update(user)
|
||||
}
|
||||
|
||||
func (s *AuthService) EnableTOTP(userID uint, secret string) error {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
user.TOTPSecret = secret
|
||||
user.TOTPEnabled = true
|
||||
return s.userRepo.Update(user)
|
||||
}
|
||||
|
||||
func (s *AuthService) DisableTOTP(userID uint) error {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
user.TOTPSecret = ""
|
||||
user.TOTPEnabled = false
|
||||
return s.userRepo.Update(user)
|
||||
}
|
||||
|
||||
75
internal/service/captcha_service.go
Normal file
75
internal/service/captcha_service.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/dchest/captcha"
|
||||
)
|
||||
|
||||
type CaptchaService struct {
|
||||
captchaType string
|
||||
turnstileSecretKey string
|
||||
}
|
||||
|
||||
func NewCaptchaService(captchaType, turnstileSecretKey string) *CaptchaService {
|
||||
return &CaptchaService{
|
||||
captchaType: captchaType,
|
||||
turnstileSecretKey: turnstileSecretKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CaptchaService) NewImageCaptcha() string {
|
||||
return captcha.New()
|
||||
}
|
||||
func (s *CaptchaService) VerifyImageCaptcha(id, answer string) bool {
|
||||
return captcha.VerifyString(id, answer)
|
||||
}
|
||||
|
||||
type turnstileResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Errors []string `json:"error-codes"`
|
||||
}
|
||||
|
||||
func (s *CaptchaService) VerifyTurnstile(token, remoteIP string) (bool, error) {
|
||||
if token == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("secret", s.turnstileSecretKey)
|
||||
form.Set("response", token)
|
||||
if remoteIP != "" {
|
||||
form.Set("remoteip", remoteIP)
|
||||
}
|
||||
|
||||
resp, err := http.Post(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
"application/x-www-form-urlencoded",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("turnstile request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("reading turnstile response: %w", err)
|
||||
}
|
||||
|
||||
var result turnstileResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return false, fmt.Errorf("parsing turnstile response: %w", err)
|
||||
}
|
||||
|
||||
return result.Success, nil
|
||||
}
|
||||
|
||||
func (s *CaptchaService) Type() string {
|
||||
return s.captchaType
|
||||
}
|
||||
71
internal/service/totp_service.go
Normal file
71
internal/service/totp_service.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"net/url"
|
||||
|
||||
"github.com/pquerna/otp"
|
||||
totplib "github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
type TOTPService struct{}
|
||||
|
||||
func NewTOTPService() *TOTPService {
|
||||
return &TOTPService{}
|
||||
}
|
||||
|
||||
type TOTPSetupData struct {
|
||||
Secret string
|
||||
QRCodeB64 string
|
||||
OTPAuthURL string
|
||||
}
|
||||
|
||||
func (s *TOTPService) GenerateSecret(email, issuer string) (*TOTPSetupData, error) {
|
||||
key, err := totplib.Generate(totplib.GenerateOpts{
|
||||
Issuer: issuer,
|
||||
AccountName: email,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.buildSetupData(key)
|
||||
}
|
||||
|
||||
func (s *TOTPService) SetupDataFromSecret(secret, email, issuer string) (*TOTPSetupData, error) {
|
||||
otpURL := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s",
|
||||
url.PathEscape(issuer),
|
||||
url.PathEscape(email),
|
||||
url.QueryEscape(secret),
|
||||
url.QueryEscape(issuer),
|
||||
)
|
||||
key, err := otp.NewKeyFromURL(otpURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.buildSetupData(key)
|
||||
}
|
||||
|
||||
func (s *TOTPService) buildSetupData(key *otp.Key) (*TOTPSetupData, error) {
|
||||
img, err := key.Image(200, 200)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TOTPSetupData{
|
||||
Secret: key.Secret(),
|
||||
QRCodeB64: base64.StdEncoding.EncodeToString(buf.Bytes()),
|
||||
OTPAuthURL: key.URL(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *TOTPService) Validate(secret, code string) bool {
|
||||
return totplib.Validate(code, secret)
|
||||
}
|
||||
Reference in New Issue
Block a user