diff --git a/config.ini.example b/config.ini.example index 246b6eb..c90a46d 100644 --- a/config.ini.example +++ b/config.ini.example @@ -58,3 +58,15 @@ rate_limit_window = 60 ; Telegram Bot Token (@BotFatherで取得) ; ユーザーはプロフィール画面でChat IDを設定します telegram_bot_token = + +[captcha] +; CAPTCHAを有効にするか (true/false) +enabled = false + +; CAPTCHAの種類: "image"(自前生成)または "turnstile"(Cloudflare Turnstile) +type = image + +; Cloudflare Turnstileを使用する場合(typeをturnstileに設定) +; Cloudflare ダッシュボードで取得したサイトキーとシークレットキーを設定 +; turnstile_site_key = 0x4AAAAAAAxxxxxxxxxxxxxxxx +; turnstile_secret_key = 0x4AAAAAAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/go.mod b/go.mod index ead45ee..4065f86 100644 --- a/go.mod +++ b/go.mod @@ -17,10 +17,12 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dchest/captcha v1.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect @@ -49,6 +51,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pquerna/otp v1.5.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.58.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect diff --git a/go.sum b/go.sum index af3856a..a6f7c96 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= @@ -11,6 +13,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ= +github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= @@ -87,6 +91,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug= diff --git a/internal/config/config.go b/internal/config/config.go index 0d50bbc..f2a04ec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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.") diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go index 54e3e6b..e95a18c 100644 --- a/internal/handler/auth_handler.go +++ b/internal/handler/auth_handler.go @@ -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 } diff --git a/internal/handler/profile_handler.go b/internal/handler/profile_handler.go index d16ecbe..a57b7c8 100644 --- a/internal/handler/profile_handler.go +++ b/internal/handler/profile_handler.go @@ -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, + }) +} diff --git a/internal/middleware/security.go b/internal/middleware/security.go index 69f89e3..6e7f936 100644 --- a/internal/middleware/security.go +++ b/internal/middleware/security.go @@ -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, "; ")) diff --git a/internal/models/user.go b/internal/models/user.go index f6d8d1b..2e0f1e2 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -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:"-"` diff --git a/internal/router/router.go b/internal/router/router.go index 5d4e469..3660564 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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()) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 987c92a..1e3f931 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -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) +} diff --git a/internal/service/captcha_service.go b/internal/service/captcha_service.go new file mode 100644 index 0000000..e9f7b02 --- /dev/null +++ b/internal/service/captcha_service.go @@ -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 +} diff --git a/internal/service/totp_service.go b/internal/service/totp_service.go new file mode 100644 index 0000000..88dcfa6 --- /dev/null +++ b/internal/service/totp_service.go @@ -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) +} diff --git a/web/templates/auth/login.html b/web/templates/auth/login.html index 5941423..ab05ca7 100644 --- a/web/templates/auth/login.html +++ b/web/templates/auth/login.html @@ -1,5 +1,11 @@ {{template "base" .}} +{{define "head"}} +{{if and .captchaEnabled (eq .captchaType "turnstile")}} + +{{end}} +{{end}} + {{define "content"}}
+
+ 認証アプリに表示されている6桁のコードを入力してください
+
+
+ 2段階認証を有効にするとセキュリティが向上します。Google Authenticator などのアプリが必要です。
+ + 2段階認証を設定する + + {{end}} +