1
0
forked from baron/baron-sso

userfront 로그인 후 /dashboard로 이동하게 변경

This commit is contained in:
Lectom C Han
2026-02-23 22:06:00 +09:00
parent 19d3bade30
commit 2bdfc2eb51
37 changed files with 1504 additions and 222 deletions

View File

@@ -1566,7 +1566,6 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
loginID := strings.TrimSpace(req.LoginID)
ale.LoginIDs["loginId"] = req.LoginID // 원문
ale.LoginIDs["loginId_normalized"] = loginID
ale.NewPassword = req.Password // For test only, logging password (sensitive)
ale.Log(slog.LevelInfo, "Attempting to login")
@@ -1602,7 +1601,6 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
ale.Status = fiber.StatusOK
ale.LatencyMs = time.Since(startTime)
ale.SessionJwt = authInfo.SessionToken.JWT
setSessionIDLocal(c, authInfo.SessionToken)
ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject))
@@ -1854,11 +1852,23 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
ale.LoginIDs["loginId"] = loginID
ale.LoginIDs["loginId_normalized"] = loginID
redirectURL := fmt.Sprintf("%s/reset-password?loginId=%s&token=%s",
os.Getenv("USERFRONT_URL"),
loginID,
token,
)
userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
if userfrontURL == "" {
userfrontURL = "https://sso.hmac.kr"
}
redirectBase, parseErr := url.Parse(userfrontURL + "/reset-password")
if parseErr != nil {
ale.Status = fiber.StatusInternalServerError
ale.LatencyMs = time.Since(startTime)
ale.ProviderError = parseErr.Error()
ale.Log(slog.LevelError, "Failed to compose reset redirect URL")
return c.Status(fiber.StatusInternalServerError).SendString("Failed to compose redirect URL")
}
query := redirectBase.Query()
query.Set("loginId", loginID)
query.Set("token", token)
redirectBase.RawQuery = query.Encode()
redirectURL := redirectBase.String()
ale.RedirectTo = redirectURL
ale.Status = fiber.StatusFound
@@ -1892,22 +1902,29 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
}
// loginID는 URL 쿼리 파라미터 또는 토큰 조회로 받습니다.
loginID := c.Query("loginId")
resetToken := c.Query("token")
if loginID == "" && resetToken != "" {
if val, err := h.RedisService.Get(prefixPwdResetToken + resetToken); err == nil && val != "" {
loginID = val
loginID := strings.TrimSpace(c.Query("loginId"))
resetToken := strings.TrimSpace(c.Query("token"))
if resetToken != "" {
val, err := h.RedisService.Get(prefixPwdResetToken + resetToken)
if err != nil || strings.TrimSpace(val) == "" {
ale.Status = fiber.StatusUnauthorized
ale.LatencyMs = time.Since(startTime)
ale.ProviderError = "Invalid or expired reset token"
ale.Token = resetToken
ale.Log(slog.LevelWarn, "Reset token invalid or expired")
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired reset token"})
}
loginID = strings.TrimSpace(val)
ale.Token = resetToken
}
if loginID != "" && !strings.Contains(loginID, "@") {
loginID = normalizePhoneForLoginID(loginID)
}
ale.LoginIDs["loginId"] = loginID
ale.RequestBody = fmt.Sprintf("{\"newPassword\": \"%s\"}", req.NewPassword) // Log request body (for test only)
ale.NewPassword = req.NewPassword // Log new password (for test only)
// Request cookie logging (minimal)
// 요청 쿠키는 원문을 기록하지 않고 존재 여부만 기록합니다.
if cookieHeader := c.Get(fiber.HeaderCookie); cookieHeader != "" {
ale.Headers["Request-Cookie-Header"] = cookieHeader
if dsrfCookie := c.Cookies("DSRF"); dsrfCookie != "" {
ale.ParsedCookieDSRF = dsrfCookie
ale.HasCookieDSRF = true
@@ -1924,7 +1941,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login ID and new password are required"})
}
// 디버깅을 위해 요청된 새 비밀번호를 로그로 출력
// 새 비밀번호 값은 기록하지 않고, 요청 수신 이벤트만 남깁니다.
ale.Log(slog.LevelInfo, "Received new password for reset")
policy := h.resolvePasswordPolicy()

View File

@@ -3,9 +3,11 @@ package handler
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
)
@@ -17,6 +19,51 @@ func newTestApp(h *AuthHandler) *fiber.App {
return app
}
func newResetFlowTestApp(h *AuthHandler) *fiber.App {
app := fiber.New()
app.Post("/api/v1/auth/password/reset/verify", h.ProcessPasswordResetToken)
app.Post("/api/v1/auth/password/reset/complete", h.CompletePasswordReset)
return app
}
type testRedisRepo struct {
values map[string]string
}
func (m *testRedisRepo) Set(key string, value string, expiration time.Duration) error {
if m.values == nil {
m.values = map[string]string{}
}
m.values[key] = value
return nil
}
func (m *testRedisRepo) Get(key string) (string, error) {
if m.values == nil {
return "", nil
}
return m.values[key], nil
}
func (m *testRedisRepo) Delete(key string) error {
if m.values != nil {
delete(m.values, key)
}
return nil
}
func (m *testRedisRepo) StoreVerificationCode(phone, code string) error {
return m.Set("sms:"+phone, code, time.Minute)
}
func (m *testRedisRepo) GetVerificationCode(phone string) (string, error) {
return m.Get("sms:" + phone)
}
func (m *testRedisRepo) DeleteVerificationCode(phone string) error {
return m.Delete("sms:" + phone)
}
func TestCompletePasswordReset_MissingLoginID(t *testing.T) {
h := &AuthHandler{}
app := newTestApp(h)
@@ -106,3 +153,136 @@ func TestCompletePasswordReset_NilIDPProvider(t *testing.T) {
t.Fatalf("unexpected error message: %v", got["error"])
}
}
func TestCompletePasswordReset_TokenValueOverridesLoginIDQuery(t *testing.T) {
const resetToken = "tok-reset-1"
const tokenLoginID = "user@example.com"
const wrongLoginID = "wrong@example.com"
const newPassword = "StrongPass1!"
redis := &testRedisRepo{
values: map[string]string{
prefixPwdResetToken + resetToken: tokenLoginID,
},
}
idp := &mockIdpProvider{
userExists: true,
err: nil,
}
h := &AuthHandler{
RedisService: redis,
IdpProvider: idp,
}
app := newResetFlowTestApp(h)
body, _ := json.Marshal(map[string]string{
"newPassword": newPassword,
})
url := fmt.Sprintf(
"/api/v1/auth/password/reset/complete?loginId=%s&token=%s",
wrongLoginID,
resetToken,
)
req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if !idp.updateCalled {
t.Fatal("expected UpdateUserPassword to be called")
}
if idp.updatedLoginID != tokenLoginID {
t.Fatalf("expected loginId from token(%s), got %s", tokenLoginID, idp.updatedLoginID)
}
if idp.updatedPassword != newPassword {
t.Fatalf("expected newPassword propagated, got %s", idp.updatedPassword)
}
}
func TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists(t *testing.T) {
const resetToken = "invalid-token"
redis := &testRedisRepo{
values: map[string]string{},
}
idp := &mockIdpProvider{
userExists: true,
err: nil,
}
h := &AuthHandler{
RedisService: redis,
IdpProvider: idp,
}
app := newResetFlowTestApp(h)
body, _ := json.Marshal(map[string]string{
"newPassword": "StrongPass1!",
})
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/password/reset/complete?loginId=user@example.com&token="+resetToken,
bytes.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 for invalid token, got %d", resp.StatusCode)
}
if idp.updateCalled {
t.Fatal("UpdateUserPassword must not be called when token is invalid")
}
}
func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
const token = "tok-enc"
const loginID = "user+alias@example.com"
t.Setenv("USERFRONT_URL", "https://sss.hmac.kr")
redis := &testRedisRepo{
values: map[string]string{
prefixPwdResetToken + token: loginID,
},
}
h := &AuthHandler{
RedisService: redis,
}
app := newResetFlowTestApp(h)
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/password/reset/verify?token="+token,
nil,
)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("expected 302, got %d", resp.StatusCode)
}
location := resp.Header.Get("Location")
if location == "" {
t.Fatal("missing redirect location")
}
redirectReq := httptest.NewRequest(http.MethodGet, location, nil)
gotLoginID := redirectReq.URL.Query().Get("loginId")
if gotLoginID != loginID {
t.Fatalf("expected encoded loginId round-trip=%s, got %s (location=%s)", loginID, gotLoginID, location)
}
}

View File

@@ -19,6 +19,9 @@ type mockIdpProvider struct {
verifyCodeInfo *domain.AuthInfo
err error
initiateLinkErr error
updateCalled bool
updatedLoginID string
updatedPassword string
}
func (m *mockIdpProvider) Name() string {
@@ -63,6 +66,9 @@ func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthIn
}
func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
m.updateCalled = true
m.updatedLoginID = loginID
m.updatedPassword = newPassword
return m.err
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/gofiber/fiber/v2"
@@ -24,7 +25,7 @@ type AuditLogEntry struct {
Origin string
Referer string
Query map[string]string
Headers map[string]string // Core headers like Host, Cookie, Set-Cookie
Headers map[string]string // 핵심 헤더(민감 키는 마스킹됨)
LoginIDs map[string]string // loginId and loginId_normalized
Token string // For reset tokens, magic link tokens
ProviderError string
@@ -43,8 +44,6 @@ type AuditLogEntry struct {
RedirectTo string
HasCookieDSRF bool
ParsedCookieDSRF string
RequestBody string // For complete stage
NewPassword string // For complete stage (test only, sensitive)
// ... potentially more fields specific to different stages
}
@@ -55,16 +54,14 @@ func NewAuditLogEntry(c *fiber.Ctx, stage string) *AuditLogEntry {
// Extract query parameters
queryParams := make(map[string]string)
c.Context().QueryArgs().VisitAll(func(key, value []byte) {
queryParams[string(key)] = string(value)
k := string(key)
queryParams[k] = maskSensitiveByKey(k, string(value))
})
// Extract relevant headers
headers := make(map[string]string)
headers["Host"] = c.Get("Host")
headers["User-Agent"] = c.Get("User-Agent")
if cookie := c.Get("Cookie"); cookie != "" {
headers["Cookie"] = cookie
}
headers["Origin"] = c.Get("Origin")
headers["Referer"] = c.Get("Referer")
@@ -122,14 +119,14 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) {
if len(ale.Query) > 0 {
queryGroupArgs := make([]any, 0, len(ale.Query))
for k, v := range ale.Query {
queryGroupArgs = append(queryGroupArgs, slog.String(k, v))
queryGroupArgs = append(queryGroupArgs, slog.String(k, maskSensitiveByKey(k, v)))
}
attrs = append(attrs, slog.Group("query", queryGroupArgs...))
}
if len(ale.Headers) > 0 {
headersGroupArgs := make([]any, 0, len(ale.Headers))
for k, v := range ale.Headers {
headersGroupArgs = append(headersGroupArgs, slog.String(k, v))
headersGroupArgs = append(headersGroupArgs, slog.String(k, maskSensitiveByKey(k, v)))
}
attrs = append(attrs, slog.Group("headers", headersGroupArgs...))
}
@@ -141,7 +138,7 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) {
attrs = append(attrs, slog.Group("login_ids", loginIDGroupArgs...))
}
if ale.Token != "" {
attrs = append(attrs, slog.String("token", ale.Token))
attrs = append(attrs, slog.Bool("has_token", true))
}
if ale.ProviderError != "" {
attrs = append(attrs, slog.String("provider_error", ale.ProviderError))
@@ -153,13 +150,13 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) {
attrs = append(attrs, slog.String("provider_response_body", ale.ProviderBody))
}
if ale.RefreshToken != "" {
attrs = append(attrs, slog.String("refresh_token", ale.RefreshToken))
attrs = append(attrs, slog.Bool("has_refresh_token", true))
}
if ale.SessionJwt != "" {
attrs = append(attrs, slog.String("session_jwt", ale.SessionJwt))
attrs = append(attrs, slog.Bool("has_session_jwt", true))
}
if ale.AccessJwt != "" {
attrs = append(attrs, slog.String("access_jwt", ale.AccessJwt))
attrs = append(attrs, slog.Bool("has_access_jwt", true))
}
if ale.UserLoginId != "" {
attrs = append(attrs, slog.String("user_login_id", ale.UserLoginId))
@@ -175,7 +172,9 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) {
}
if ale.SetCookieName != "" {
attrs = append(attrs, slog.String("set_cookie_name", ale.SetCookieName))
attrs = append(attrs, slog.String("set_cookie_value", ale.SetCookieValue))
if ale.SetCookieValue != "" {
attrs = append(attrs, slog.Bool("has_set_cookie_value", true))
}
if len(ale.SetCookieAttrs) > 0 {
cookieAttrsGroupArgs := make([]any, 0, len(ale.SetCookieAttrs))
for k, v := range ale.SetCookieAttrs {
@@ -191,13 +190,7 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) {
attrs = append(attrs, slog.Bool("has_cookie_DSRF", ale.HasCookieDSRF))
}
if ale.ParsedCookieDSRF != "" {
attrs = append(attrs, slog.String("parsed_cookie_DSRF", ale.ParsedCookieDSRF))
}
if ale.RequestBody != "" {
attrs = append(attrs, slog.String("request_body", ale.RequestBody))
}
if ale.NewPassword != "" { // FOR TEST ONLY - DO NOT LOG IN PRODUCTION
attrs = append(attrs, slog.String("new_password", ale.NewPassword))
attrs = append(attrs, slog.Bool("has_parsed_cookie_DSRF", true))
}
// Convert variadic args to slog.Attr before appending
@@ -212,3 +205,36 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) {
slog.Default().LogAttrs(context.Background(), level, msg, attrs...)
}
var sensitiveAuditKeys = map[string]struct{}{
"password": {},
"currentpassword": {},
"newpassword": {},
"oldpassword": {},
"token": {},
"accesstoken": {},
"refreshtoken": {},
"authorization": {},
"cookie": {},
"setcookie": {},
"verificationcode": {},
"code": {},
"loginchallenge": {},
"loginverifier": {},
"sessionjwt": {},
"accessjwt": {},
"refreshjwt": {},
}
func maskSensitiveByKey(key, value string) string {
if value == "" {
return value
}
k := strings.ToLower(key)
k = strings.ReplaceAll(k, "-", "")
k = strings.ReplaceAll(k, "_", "")
if _, ok := sensitiveAuditKeys[k]; ok {
return "*****"
}
return value
}

View File

@@ -0,0 +1,80 @@
package logger
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAuditLogEntry_RedactsSensitiveFields(t *testing.T) {
buf := &bytes.Buffer{}
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(buf, nil)))
defer slog.SetDefault(previous)
ale := &AuditLogEntry{
RequestID: "req-1",
Stage: "login",
Token: "tok-secret",
RefreshToken: "refresh-secret",
SessionJwt: "session-secret",
AccessJwt: "access-secret",
SetCookieName: "sid",
SetCookieValue: "cookie-secret",
ParsedCookieDSRF: "dsrf-secret",
LoginIDs: map[string]string{
"loginId": "user@example.com",
},
Query: map[string]string{
"token": "query-token",
"locale": "ko",
},
Headers: map[string]string{
"Authorization": "Bearer secret",
"Cookie": "session=secret",
},
}
ale.Log(slog.LevelInfo, "test")
line := strings.TrimSpace(buf.String())
require.NotEmpty(t, line)
var payload map[string]any
require.NoError(t, json.Unmarshal([]byte(line), &payload))
assert.NotContains(t, payload, "token")
assert.NotContains(t, payload, "refresh_token")
assert.NotContains(t, payload, "session_jwt")
assert.NotContains(t, payload, "access_jwt")
assert.NotContains(t, payload, "set_cookie_value")
assert.NotContains(t, payload, "parsed_cookie_DSRF")
assert.NotContains(t, payload, "request_body")
assert.NotContains(t, payload, "new_password")
assert.Equal(t, true, payload["has_token"])
assert.Equal(t, true, payload["has_refresh_token"])
assert.Equal(t, true, payload["has_session_jwt"])
assert.Equal(t, true, payload["has_access_jwt"])
assert.Equal(t, true, payload["has_set_cookie_value"])
assert.Equal(t, true, payload["has_parsed_cookie_DSRF"])
loginIDs, ok := payload["login_ids"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "user@example.com", loginIDs["loginId"])
query, ok := payload["query"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "*****", query["token"])
assert.Equal(t, "ko", query["locale"])
headers, ok := payload["headers"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "*****", headers["Authorization"])
assert.Equal(t, "*****", headers["Cookie"])
}