CAPTCHAと2FAを実装

This commit is contained in:
2026-03-24 18:40:38 +09:00
parent 080bd1f8d7
commit 1113477111
17 changed files with 798 additions and 40 deletions

View File

@@ -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
}

View File

@@ -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,
})
}