CAPTCHAと2FAを実装
This commit is contained in:
@@ -58,3 +58,15 @@ rate_limit_window = 60
|
|||||||
; Telegram Bot Token (@BotFatherで取得)
|
; Telegram Bot Token (@BotFatherで取得)
|
||||||
; ユーザーはプロフィール画面でChat IDを設定します
|
; ユーザーはプロフィール画面でChat IDを設定します
|
||||||
telegram_bot_token =
|
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
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -17,10 +17,12 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
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/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.14.2 // indirect
|
github.com/bytedance/sonic v1.14.2 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // 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/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // 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/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // 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/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.58.0 // indirect
|
github.com/quic-go/quic-go v0.58.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -1,5 +1,7 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
|
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ type NotificationConfig struct {
|
|||||||
TelegramBotToken string
|
TelegramBotToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CaptchaConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
Type string // "turnstile" or "image"
|
||||||
|
TurnstileSiteKey string
|
||||||
|
TurnstileSecretKey string
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port string
|
Port string
|
||||||
SessionSecret string
|
SessionSecret string
|
||||||
@@ -34,6 +41,7 @@ type Config struct {
|
|||||||
TrustedProxies []string
|
TrustedProxies []string
|
||||||
Database DatabaseConfig
|
Database DatabaseConfig
|
||||||
Notification NotificationConfig
|
Notification NotificationConfig
|
||||||
|
Captcha CaptchaConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(configPath string) *Config {
|
func Load(configPath string) *Config {
|
||||||
@@ -56,6 +64,10 @@ func Load(configPath string) *Config {
|
|||||||
Password: "",
|
Password: "",
|
||||||
Name: "homework_manager",
|
Name: "homework_manager",
|
||||||
},
|
},
|
||||||
|
Captcha: CaptchaConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Type: "image",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if configPath == "" {
|
if configPath == "" {
|
||||||
@@ -134,6 +146,21 @@ func Load(configPath string) *Config {
|
|||||||
if section.HasKey("telegram_bot_token") {
|
if section.HasKey("telegram_bot_token") {
|
||||||
cfg.Notification.TelegramBotToken = section.Key("telegram_bot_token").String()
|
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 {
|
} else {
|
||||||
log.Println("config.ini not found, using environment variables or defaults")
|
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 != "" {
|
if telegramToken := os.Getenv("TELEGRAM_BOT_TOKEN"); telegramToken != "" {
|
||||||
cfg.Notification.TelegramBotToken = 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 == "" {
|
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.")
|
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 (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"homework-manager/internal/config"
|
||||||
"homework-manager/internal/middleware"
|
"homework-manager/internal/middleware"
|
||||||
"homework-manager/internal/service"
|
"homework-manager/internal/service"
|
||||||
|
|
||||||
@@ -10,33 +11,96 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const twoFAPendingKey = "2fa_pending_user_id"
|
||||||
|
|
||||||
type AuthHandler struct {
|
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{
|
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) {
|
func (h *AuthHandler) ShowLogin(c *gin.Context) {
|
||||||
RenderHTML(c, http.StatusOK, "login.html", gin.H{
|
data := gin.H{"title": "ログイン"}
|
||||||
"title": "ログイン",
|
for k, v := range h.captchaData() {
|
||||||
})
|
data[k] = v
|
||||||
|
}
|
||||||
|
RenderHTML(c, http.StatusOK, "login.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Login(c *gin.Context) {
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
email := c.PostForm("email")
|
email := c.PostForm("email")
|
||||||
password := c.PostForm("password")
|
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)
|
user, err := h.authService.Login(email, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
RenderHTML(c, http.StatusOK, "login.html", gin.H{
|
renderLoginError("メールアドレスまたはパスワードが正しくありません")
|
||||||
"title": "ログイン",
|
return
|
||||||
"error": "メールアドレスまたはパスワードが正しくありません",
|
}
|
||||||
"email": email,
|
|
||||||
})
|
if user.TOTPEnabled {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Set(twoFAPendingKey, user.ID)
|
||||||
|
session.Save()
|
||||||
|
c.Redirect(http.StatusFound, "/login/2fa")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,35 +113,91 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/")
|
c.Redirect(http.StatusFound, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) ShowRegister(c *gin.Context) {
|
func (h *AuthHandler) ShowLogin2FA(c *gin.Context) {
|
||||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
session := sessions.Default(c)
|
||||||
"title": "新規登録",
|
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) {
|
func (h *AuthHandler) Register(c *gin.Context) {
|
||||||
email := c.PostForm("email")
|
email := c.PostForm("email")
|
||||||
password := c.PostForm("password")
|
password := c.PostForm("password")
|
||||||
passwordConfirm := c.PostForm("password_confirm")
|
passwordConfirm := c.PostForm("password_confirm")
|
||||||
name := c.PostForm("name")
|
name := c.PostForm("name")
|
||||||
|
|
||||||
if password != passwordConfirm {
|
renderRegisterError := func(msg string) {
|
||||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
data := gin.H{
|
||||||
"title": "新規登録",
|
"title": "新規登録",
|
||||||
"error": "パスワードが一致しません",
|
"error": msg,
|
||||||
"email": email,
|
"email": email,
|
||||||
"name": name,
|
"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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(password) < 8 {
|
if len(password) < 8 {
|
||||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
renderRegisterError("パスワードは8文字以上で入力してください")
|
||||||
"title": "新規登録",
|
|
||||||
"error": "パスワードは8文字以上で入力してください",
|
|
||||||
"email": email,
|
|
||||||
"name": name,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,12 +207,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
|||||||
if err == service.ErrEmailAlreadyExists {
|
if err == service.ErrEmailAlreadyExists {
|
||||||
errorMsg = "このメールアドレスは既に使用されています"
|
errorMsg = "このメールアドレスは既に使用されています"
|
||||||
}
|
}
|
||||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
renderRegisterError(errorMsg)
|
||||||
"title": "新規登録",
|
|
||||||
"error": errorMsg,
|
|
||||||
"email": email,
|
|
||||||
"name": name,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,23 @@ import (
|
|||||||
"homework-manager/internal/models"
|
"homework-manager/internal/models"
|
||||||
"homework-manager/internal/service"
|
"homework-manager/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProfileHandler struct {
|
type ProfileHandler struct {
|
||||||
authService *service.AuthService
|
authService *service.AuthService
|
||||||
|
totpService *service.TOTPService
|
||||||
notificationService *service.NotificationService
|
notificationService *service.NotificationService
|
||||||
|
appName string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProfileHandler(notificationService *service.NotificationService) *ProfileHandler {
|
func NewProfileHandler(notificationService *service.NotificationService) *ProfileHandler {
|
||||||
return &ProfileHandler{
|
return &ProfileHandler{
|
||||||
authService: service.NewAuthService(),
|
authService: service.NewAuthService(),
|
||||||
|
totpService: service.NewTOTPService(),
|
||||||
notificationService: notificationService,
|
notificationService: notificationService,
|
||||||
|
appName: "Super-HomeworkManager",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,3 +176,135 @@ func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
|
|||||||
"notifySettings": notifySettings,
|
"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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,20 +8,32 @@ import (
|
|||||||
|
|
||||||
type SecurityConfig struct {
|
type SecurityConfig struct {
|
||||||
HTTPS bool
|
HTTPS bool
|
||||||
|
TurnstileEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func SecurityHeaders(config SecurityConfig) gin.HandlerFunc {
|
func SecurityHeaders(config SecurityConfig) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if config.HTTPS {
|
if config.HTTPS {
|
||||||
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
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{
|
csp := []string{
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
"script-src " + scriptSrc,
|
||||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
||||||
"font-src 'self' https://cdn.jsdelivr.net",
|
"font-src 'self' https://cdn.jsdelivr.net",
|
||||||
"img-src 'self' data:",
|
"img-src 'self' data:",
|
||||||
"connect-src 'self'",
|
"connect-src " + connectSrc,
|
||||||
|
"frame-src " + frameSrc,
|
||||||
"frame-ancestors 'none'",
|
"frame-ancestors 'none'",
|
||||||
}
|
}
|
||||||
c.Header("Content-Security-Policy", strings.Join(csp, "; "))
|
c.Header("Content-Security-Policy", strings.Join(csp, "; "))
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ type User struct {
|
|||||||
PasswordHash string `gorm:"not null" json:"-"`
|
PasswordHash string `gorm:"not null" json:"-"`
|
||||||
Name string `gorm:"not null" json:"name"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Role string `gorm:"not null;default:user" json:"role"` // "admin" or "user"
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"homework-manager/internal/middleware"
|
"homework-manager/internal/middleware"
|
||||||
"homework-manager/internal/service"
|
"homework-manager/internal/service"
|
||||||
|
|
||||||
|
"github.com/dchest/captcha"
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-contrib/sessions/cookie"
|
"github.com/gin-contrib/sessions/cookie"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -176,6 +177,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
|||||||
|
|
||||||
securityConfig := middleware.SecurityConfig{
|
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.SecurityHeaders(securityConfig))
|
||||||
r.Use(middleware.ForceHTTPS(securityConfig))
|
r.Use(middleware.ForceHTTPS(securityConfig))
|
||||||
@@ -196,13 +198,22 @@ func Setup(cfg *config.Config) *gin.Engine {
|
|||||||
|
|
||||||
notificationService.StartReminderScheduler()
|
notificationService.StartReminderScheduler()
|
||||||
|
|
||||||
authHandler := handler.NewAuthHandler()
|
authHandler := handler.NewAuthHandler(cfg.Captcha)
|
||||||
assignmentHandler := handler.NewAssignmentHandler(notificationService)
|
assignmentHandler := handler.NewAssignmentHandler(notificationService)
|
||||||
adminHandler := handler.NewAdminHandler()
|
adminHandler := handler.NewAdminHandler()
|
||||||
profileHandler := handler.NewProfileHandler(notificationService)
|
profileHandler := handler.NewProfileHandler(notificationService)
|
||||||
apiHandler := handler.NewAPIHandler()
|
apiHandler := handler.NewAPIHandler()
|
||||||
apiRecurringHandler := handler.NewAPIRecurringHandler()
|
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 := r.Group("/")
|
||||||
guest.Use(middleware.GuestOnly())
|
guest.Use(middleware.GuestOnly())
|
||||||
guest.Use(csrfMiddleware)
|
guest.Use(csrfMiddleware)
|
||||||
@@ -252,6 +263,9 @@ func Setup(cfg *config.Config) *gin.Engine {
|
|||||||
auth.POST("/profile", profileHandler.Update)
|
auth.POST("/profile", profileHandler.Update)
|
||||||
auth.POST("/profile/password", profileHandler.ChangePassword)
|
auth.POST("/profile/password", profileHandler.ChangePassword)
|
||||||
auth.POST("/profile/notifications", profileHandler.UpdateNotificationSettings)
|
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 := auth.Group("/admin")
|
||||||
admin.Use(middleware.AdminRequired())
|
admin.Use(middleware.AdminRequired())
|
||||||
|
|||||||
@@ -104,3 +104,23 @@ func (s *AuthService) UpdateProfile(userID uint, name string) error {
|
|||||||
user.Name = name
|
user.Name = name
|
||||||
return s.userRepo.Update(user)
|
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)
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
{{template "base" .}}
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "head"}}
|
||||||
|
{{if and .captchaEnabled (eq .captchaType "turnstile")}}
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-md-5 col-lg-4">
|
<div class="col-md-5 col-lg-4">
|
||||||
@@ -25,6 +31,28 @@
|
|||||||
<label for="password" class="form-label">パスワード</label>
|
<label for="password" class="form-label">パスワード</label>
|
||||||
<input type="password" class="form-control" id="password" name="password" required>
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if .captchaEnabled}}
|
||||||
|
{{if eq .captchaType "turnstile"}}
|
||||||
|
<div class="mb-3 d-flex justify-content-center">
|
||||||
|
<div class="cf-turnstile" data-sitekey="{{.turnstileSiteKey}}"></div>
|
||||||
|
</div>
|
||||||
|
{{else if eq .captchaType "image"}}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">画像認証</label>
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
|
<img src="/captcha/{{.captchaID}}.png" alt="CAPTCHA" class="border rounded" id="captchaImg">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="reloadCaptcha()" title="更新">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="captcha_id" id="captchaID" value="{{.captchaID}}">
|
||||||
|
<input type="text" class="form-control" name="captcha_answer"
|
||||||
|
placeholder="上の数字を入力" autocomplete="off" required>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="d-grid">
|
<div class="d-grid">
|
||||||
<button type="submit" class="btn btn-primary btn-lg">ログイン</button>
|
<button type="submit" class="btn btn-primary btn-lg">ログイン</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,3 +69,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
{{if and .captchaEnabled (eq .captchaType "image")}}
|
||||||
|
<script>
|
||||||
|
function reloadCaptcha() {
|
||||||
|
fetch('/captcha-new')
|
||||||
|
.then(r => r.text())
|
||||||
|
.then(id => {
|
||||||
|
document.getElementById('captchaID').value = id;
|
||||||
|
document.getElementById('captchaImg').src = '/captcha/' + id + '.png?' + Date.now();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|||||||
46
web/templates/auth/login_2fa.html
Normal file
46
web/templates/auth/login_2fa.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5 col-lg-4">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<i class="bi bi-shield-lock display-4 text-primary"></i>
|
||||||
|
<h2 class="mt-2">2段階認証</h2>
|
||||||
|
<p class="text-muted small">認証アプリに表示されている6桁のコードを入力してください</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .error}}
|
||||||
|
<div class="alert alert-danger">{{.error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="POST" action="/login/2fa">
|
||||||
|
{{.csrfField}}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="totp_code" class="form-label">認証コード</label>
|
||||||
|
<input type="text" class="form-control form-control-lg text-center"
|
||||||
|
id="totp_code" name="totp_code"
|
||||||
|
placeholder="000000"
|
||||||
|
maxlength="6" pattern="[0-9]{6}"
|
||||||
|
inputmode="numeric" autocomplete="one-time-code"
|
||||||
|
autofocus required>
|
||||||
|
<div class="form-text text-center">Google Authenticator などのアプリで確認</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
<i class="bi bi-shield-check me-1"></i>確認
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="/login" class="text-muted small">別のアカウントでログイン</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
{{template "base" .}}
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "head"}}
|
||||||
|
{{if and .captchaEnabled (eq .captchaType "turnstile")}}
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-md-5 col-lg-4">
|
<div class="col-md-5 col-lg-4">
|
||||||
@@ -28,14 +34,36 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="password" class="form-label">パスワード</label>
|
<label for="password" class="form-label">パスワード</label>
|
||||||
<input type="password" class="form-control" id="password" name="password" required
|
<input type="password" class="form-control" id="password" name="password" required
|
||||||
minlength="6">
|
minlength="8">
|
||||||
<div class="form-text">6文字以上</div>
|
<div class="form-text">8文字以上</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="password_confirm" class="form-label">パスワード(確認)</label>
|
<label for="password_confirm" class="form-label">パスワード(確認)</label>
|
||||||
<input type="password" class="form-control" id="password_confirm" name="password_confirm"
|
<input type="password" class="form-control" id="password_confirm" name="password_confirm"
|
||||||
required minlength="6">
|
required minlength="8">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if .captchaEnabled}}
|
||||||
|
{{if eq .captchaType "turnstile"}}
|
||||||
|
<div class="mb-3 d-flex justify-content-center">
|
||||||
|
<div class="cf-turnstile" data-sitekey="{{.turnstileSiteKey}}"></div>
|
||||||
|
</div>
|
||||||
|
{{else if eq .captchaType "image"}}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">画像認証</label>
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
|
<img src="/captcha/{{.captchaID}}.png" alt="CAPTCHA" class="border rounded" id="captchaImg">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="reloadCaptcha()" title="更新">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="captcha_id" id="captchaID" value="{{.captchaID}}">
|
||||||
|
<input type="text" class="form-control" name="captcha_answer"
|
||||||
|
placeholder="上の数字を入力" autocomplete="off" required>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="d-grid">
|
<div class="d-grid">
|
||||||
<button type="submit" class="btn btn-primary btn-lg">登録</button>
|
<button type="submit" class="btn btn-primary btn-lg">登録</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,3 +80,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
{{if and .captchaEnabled (eq .captchaType "image")}}
|
||||||
|
<script>
|
||||||
|
function reloadCaptcha() {
|
||||||
|
fetch('/captcha-new')
|
||||||
|
.then(r => r.text())
|
||||||
|
.then(id => {
|
||||||
|
document.getElementById('captchaID').value = id;
|
||||||
|
document.getElementById('captchaImg').src = '/captcha/' + id + '.png?' + Date.now();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|||||||
@@ -67,6 +67,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 2段階認証設定 -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>2段階認証(2FA)</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{if .totpError}}<div class="alert alert-danger">{{.totpError}}</div>{{end}}
|
||||||
|
{{if .totpSuccess}}<div class="alert alert-success">{{.totpSuccess}}</div>{{end}}
|
||||||
|
{{if .user.TOTPEnabled}}
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<span class="badge bg-success me-2"><i class="bi bi-check-lg me-1"></i>有効</span>
|
||||||
|
<span class="text-muted">2段階認証が有効になっています</span>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/profile/totp/disable">
|
||||||
|
{{.csrfField}}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="totp_disable_password" class="form-label">現在のパスワードを入力して無効化</label>
|
||||||
|
<input type="password" class="form-control" id="totp_disable_password" name="password"
|
||||||
|
placeholder="パスワード" required style="max-width:320px">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<i class="bi bi-shield-x me-1"></i>2段階認証を無効化
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<span class="badge bg-secondary me-2"><i class="bi bi-x-lg me-1"></i>無効</span>
|
||||||
|
<span class="text-muted">2段階認証が設定されていません</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small">2段階認証を有効にするとセキュリティが向上します。Google Authenticator などのアプリが必要です。</p>
|
||||||
|
<a href="/profile/totp/setup" class="btn btn-primary">
|
||||||
|
<i class="bi bi-shield-plus me-1"></i>2段階認証を設定する
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 通知設定 -->
|
<!-- 通知設定 -->
|
||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
|
|||||||
83
web/templates/pages/totp_setup.html
Normal file
83
web/templates/pages/totp_setup.html
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-7 col-lg-6">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>2段階認証の設定</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4">
|
||||||
|
{{if .error}}
|
||||||
|
<div class="alert alert-danger">{{.error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
Google Authenticator、Authy などの認証アプリを使用してください。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="fw-bold mb-3">手順</h6>
|
||||||
|
<ol class="mb-4">
|
||||||
|
<li class="mb-2">認証アプリを開き、新しいアカウントを追加してください。</li>
|
||||||
|
<li class="mb-2">下のQRコードをスキャンするか、シークレットキーを手動で入力してください。</li>
|
||||||
|
<li class="mb-2">アプリに表示された6桁のコードを下の欄に入力して確認してください。</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<img src="data:image/png;base64,{{.qrCode}}" alt="QRコード" class="border rounded"
|
||||||
|
style="max-width:200px">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-bold">シークレットキー(手動入力の場合)</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control font-monospace" id="secretKey" value="{{.secret}}"
|
||||||
|
readonly>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="copySecret()" title="コピー">
|
||||||
|
<i class="bi bi-clipboard"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">認証アプリで「手動入力」を選択し、このキーを入力してください。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="/profile/totp/setup">
|
||||||
|
{{.csrfField}}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="totp_password" class="form-label fw-bold">現在のパスワード</label>
|
||||||
|
<input type="password" class="form-control" id="totp_password" name="password"
|
||||||
|
placeholder="パスワードを入力" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="totp_code" class="form-label fw-bold">認証コードで確認</label>
|
||||||
|
<input type="text" class="form-control form-control-lg text-center" id="totp_code"
|
||||||
|
name="totp_code" placeholder="000000" maxlength="6" pattern="[0-9]{6}" inputmode="numeric"
|
||||||
|
autocomplete="off" autofocus required>
|
||||||
|
<div class="form-text">認証アプリに表示された6桁のコードを入力してください。</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-shield-check me-1"></i>有効化
|
||||||
|
</button>
|
||||||
|
<a href="/profile" class="btn btn-outline-secondary">キャンセル</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script>
|
||||||
|
function copySecret() {
|
||||||
|
const el = document.getElementById('secretKey');
|
||||||
|
el.select();
|
||||||
|
navigator.clipboard.writeText(el.value).then(() => {
|
||||||
|
const btn = el.nextElementSibling;
|
||||||
|
btn.innerHTML = '<i class="bi bi-check-lg"></i>';
|
||||||
|
setTimeout(() => { btn.innerHTML = '<i class="bi bi-clipboard"></i>'; }, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user