Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func main() {
jwtSecret := []byte(getEnv("JWT_SECRET", "dev-secret-change-in-production"))
bypass := getEnv("AUTH_BYPASS", "") == "true"
allowDomains := splitCSV(os.Getenv("ALLOWED_DOMAINS"))
adminGroups := splitCSV(os.Getenv("ADMIN_GROUPS"))

// ── DB マイグレーション ──
log.Println("running migrations...")
Expand Down Expand Up @@ -78,7 +79,7 @@ func main() {
submitHandler := handler.NewSubmitHandler(judgeUsecase)
problemHandler := handler.NewProblemHandler(problemRepo)
submissionHandler := handler.NewSubmissionHandler(submissionRepo)
authUsecase := usecase.NewAuthUsecase(userRepo, allowedEmailRepo, allowDomains, jwtSecret)
authUsecase := usecase.NewAuthUsecase(userRepo, allowedEmailRepo, allowDomains, jwtSecret, adminGroups)
authHandler := handler.NewAuthHandler(authUsecase, providers, frontendOrigin, bypass)

// ── Echo ルーター ──
Expand Down Expand Up @@ -171,6 +172,30 @@ func buildProviders(ctx context.Context) []*handler.ProviderConfig {
log.Println("oidc provider registered: keycloak")
}

// authentik は標準 OIDC に準拠しており Keycloak と同じフローで動作する。
// groups クレームを ID Token に含める場合は authentik 側で Property Mapping を設定すること:
// Provider → Edit → Advanced → Scopes に groups マッピングを追加
if clientID := os.Getenv("AUTHENTIK_CLIENT_ID"); clientID != "" {
issuer := os.Getenv("AUTHENTIK_ISSUER")
p, err := oidc.NewProvider(ctx, issuer)
if err != nil {
log.Fatalf("init authentik oidc provider: %v", err)
}
providers = append(providers, &handler.ProviderConfig{
Name: "authentik",
OAuth2: &oauth2.Config{
ClientID: clientID,
ClientSecret: os.Getenv("AUTHENTIK_CLIENT_SECRET"),
RedirectURL: os.Getenv("AUTHENTIK_CALLBACK_URL"),
// openid/email/profile に加え groups クレームを要求する
Scopes: []string{oidc.ScopeOpenID, "email", "profile"},
Endpoint: p.Endpoint(),
},
Verifier: oidcinfra.NewVerifier(p.Verifier(&oidc.Config{ClientID: clientID})),
})
log.Println("oidc provider registered: authentik")
}

return providers
}

Expand Down
3 changes: 3 additions & 0 deletions backend/internal/domain/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ type OIDCClaims struct {
Email string
EmailVerified bool
Name string
// Groups はプロバイダーが発行したグループ一覧 (例: authentik の groups claim)。
// グループクレームを持たないプロバイダー (Google 等) では nil になる。
Groups []string
}

// AppClaims はアプリ独自 JWT のペイロード
Expand Down
20 changes: 13 additions & 7 deletions backend/internal/infrastructure/db/repository/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func (r *UserRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.Use
// 3. どちらも存在しない → users + user_identities を INSERT して返す
func (r *UserRepository) UpsertWithIdentity(
ctx context.Context,
email, name, provider, providerSub string,
email, name, provider, providerSub, roleHint string,
) (*domain.User, error) {
tx, err := r.db.Begin(ctx)
if err != nil {
Expand All @@ -71,15 +71,19 @@ func (r *UserRepository) UpsertWithIdentity(
}

if err == nil {
// identity が既存 → ユーザーを返す(name のみ更新)
// identity が既存 → name と roleHint を更新
// roleHint が空のときは既存ロールを維持する
var u domain.User
var idStr string
if err := tx.QueryRow(ctx, `
UPDATE users SET name = $2, updated_at = NOW()
UPDATE users
SET name = $2,
role = CASE WHEN $3 != '' THEN $3 ELSE role END,
updated_at = NOW()
WHERE id = $1
RETURNING id, email, name, role, created_at, updated_at
`, userIDStr, name).Scan(&idStr, &u.Email, &u.Name, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("update user name: %w", err)
`, userIDStr, name, roleHint).Scan(&idStr, &u.Email, &u.Name, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
u.ID, err = uuid.Parse(idStr)
if err != nil {
Expand All @@ -105,10 +109,12 @@ func (r *UserRepository) UpsertWithIdentity(

if err != nil {
// 3. 新規ユーザー作成
// roleHint が空のときはデフォルト ('student') をそのまま使う
if err := tx.QueryRow(ctx, `
INSERT INTO users (email, name) VALUES ($1, $2)
INSERT INTO users (email, name, role)
VALUES ($1, $2, CASE WHEN $3 != '' THEN $3 ELSE 'student' END)
RETURNING id, email, name, role, created_at, updated_at
`, email, name).Scan(&idStr, &u.Email, &u.Name, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil {
`, email, name, roleHint).Scan(&idStr, &u.Email, &u.Name, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("insert user: %w", err)
}
}
Expand Down
14 changes: 9 additions & 5 deletions backend/internal/infrastructure/oidc/verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,14 @@ func (v *Verifier) Verify(ctx context.Context, rawIDToken, expectedNonce string)
}

var c struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
Nonce string `json:"nonce"`
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
Nonce string `json:"nonce"`
// groups は authentik などのプロバイダーが発行するグループクレーム。
// Google など非対応プロバイダーでは nil になる。
Groups []string `json:"groups"`
}
if err := idToken.Claims(&c); err != nil {
return nil, err
Expand All @@ -47,5 +50,6 @@ func (v *Verifier) Verify(ctx context.Context, rawIDToken, expectedNonce string)
Email: c.Email,
EmailVerified: c.EmailVerified,
Name: c.Name,
Groups: c.Groups,
}, nil
}
30 changes: 28 additions & 2 deletions backend/internal/usecase/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import (

// AuthUserRepository はユーザーの永続化操作を定義する
type AuthUserRepository interface {
UpsertWithIdentity(ctx context.Context, email, name, provider, providerSub string) (*domain.User, error)
// UpsertWithIdentity は JIT プロビジョニングを行う。
// roleHint が空文字でない場合、ログイン時にロールを上書きする (SSO グループ同期用)。
UpsertWithIdentity(ctx context.Context, email, name, provider, providerSub, roleHint string) (*domain.User, error)
}

// AuthAllowedEmailRepository は個別許可メールアドレスの参照を定義する
Expand All @@ -27,19 +29,24 @@ type AuthUsecase struct {
allowedEmailRepo AuthAllowedEmailRepository
allowDomains []string
jwtSecret []byte
// adminGroups は admin ロールを付与するグループ名一覧 (authentik の groups claim と照合)。
// 空のときはグループによるロール同期を行わない。
adminGroups []string
}

func NewAuthUsecase(
userRepo AuthUserRepository,
allowedEmailRepo AuthAllowedEmailRepository,
allowDomains []string,
jwtSecret []byte,
adminGroups []string,
) *AuthUsecase {
return &AuthUsecase{
userRepo: userRepo,
allowedEmailRepo: allowedEmailRepo,
allowDomains: allowDomains,
jwtSecret: jwtSecret,
adminGroups: adminGroups,
}
}

Expand All @@ -58,9 +65,10 @@ func (u *AuthUsecase) ProcessLogin(ctx context.Context, provider string, claims
return "", &AuthError{Code: "access_denied"}
}

roleHint := u.resolveRoleHint(claims.Groups)
user, err := u.userRepo.UpsertWithIdentity(
ctx,
strings.ToLower(claims.Email), claims.Name, provider, claims.Sub,
strings.ToLower(claims.Email), claims.Name, provider, claims.Sub, roleHint,
)
if err != nil {
return "", fmt.Errorf("provision user: %w", err)
Expand Down Expand Up @@ -116,6 +124,24 @@ func (u *AuthUsecase) issueAppToken(user *domain.User) (string, error) {
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(u.jwtSecret)
}

// resolveRoleHint は OIDC の groups クレームから roleHint を決定する。
// adminGroups が設定されていて groups 内に一致するものがあれば "admin"、
// adminGroups が設定されていて一致しなければ "student" を返す。
// adminGroups 未設定のときは "" を返し、ロールを変更しない。
func (u *AuthUsecase) resolveRoleHint(groups []string) string {
if len(u.adminGroups) == 0 {
return ""
}
for _, g := range groups {
for _, ag := range u.adminGroups {
if strings.EqualFold(g, ag) {
return "admin"
}
}
}
return "student"
}

// AuthError は認証ビジネスロジックのエラー型
type AuthError struct {
Code string // "email_not_verified" | "access_denied"
Expand Down