본문으로 건너뛰기

종합 실습 — 인증이 붙은 API 서버

이 챕터에서 다루는 것

파트 10을 하나로 합친다. 9-8의 할 일 API에 로그인과 권한이 붙는다.

엔드포인트인증하는 일
GET /healthz없음상태 확인
POST /auth/login없음액세스 + 리프레시 토큰 발급
POST /auth/refresh리프레시 토큰토큰 쌍 재발급
GET /me액세스 토큰내 정보
GET /tasks액세스 토큰 할 일 목록
POST /tasks액세스 토큰생성
GET /tasks/{id}액세스 토큰내 것만
PATCH /tasks/{id}액세스 토큰내 것만
DELETE /tasks/{id}액세스 토큰내 것만
DELETE /admin/tasks/{id}액세스 토큰 + admin남의 것도

9-8의 에러 응답 형식(code/message/fields/request_id)과 400/422 규약을 그대로 유지한다. 클라이언트를 다시 짜지 않아도 되게.

구조

08-practice/
├── main.go
└── api/
├── store.go 사용자 저장소(비밀번호 해싱) + 할 일 저장소(소유자별)
├── auth.go 인증·인가 미들웨어, 로그인, 갱신
├── api.go 라우팅, 할 일 핸들러, 공통 미들웨어
└── validate.go 검증 태그 등록과 메시지

토큰은 10-7token 패키지를 그대로 쓴다.

비밀번호 — 표준 라이브러리로 충분하다

examples/10-web-frameworks/08-practice/api/store.go
// pbkdf2Iter는 반복 횟수다. 실제 서비스에서는 하드웨어에 맞춰 올린다.
// 예제에서는 테스트가 빨리 끝나도록 낮게 잡았다.
const pbkdf2Iter = 10_000

// hashPassword는 솔트를 섞어 키를 유도한다.
// crypto/pbkdf2는 Go 1.24에서 표준 라이브러리에 들어왔다.
// 원문을 그대로 저장하거나 sha256 한 번만 돌리는 것은 둘 다 사고다.
func hashPassword(password string, salt []byte) ([]byte, error) {
key, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Iter, 32)
if err != nil {
return nil, fmt.Errorf("api: 키 유도 실패: %w", err)
}
return key, nil
}

crypto/pbkdf2가 Go 1.24부터 표준 라이브러리에 있다. bcrypt를 쓰려면 golang.org/x/crypto가 필요하지만, 여기서는 의존성 없이 끝난다.

핵심은 느려야 한다는 것이다. sha256을 한 번 돌리는 것은 GPU로 초당 수십억 번 시도할 수 있어 무의미하다. PBKDF2는 반복 횟수로 그 속도를 늦춘다.

인증 함수에는 두 가지 방어가 더 들어 있다.

examples/10-web-frameworks/08-practice/api/store.go
// Authenticate는 이메일과 비밀번호를 확인한다.
func (s *UserStore) Authenticate(email, password string) (*User, error) {
s.mu.RLock()
u, ok := s.byEmail[email]
s.mu.RUnlock()

if !ok {
// 존재하지 않는 계정에도 같은 시간을 쓰도록 해시를 한 번 돌린다.
// 응답 시간 차이로 계정 존재 여부를 알아내는 것을 막는다.
if _, err := hashPassword(password, []byte("dummy-salt")); err != nil {
return nil, err
}
return nil, ErrBadCredentials
}

got, err := hashPassword(password, u.salt)
if err != nil {
return nil, err
}
// 바이트 비교는 상수 시간으로 한다. bytes.Equal은 빨리 끝나는 만큼
// 어디까지 일치했는지가 시간으로 새어 나간다.
if subtle.ConstantTimeCompare(got, u.hash) != 1 {
return nil, ErrBadCredentials
}
return u, nil
}
  • 없는 계정에도 해시를 한 번 돌린다. 안 그러면 응답 시간이 눈에 띄게 달라져 "이 이메일은 가입되어 있다"가 새어 나간다.
  • subtle.ConstantTimeCompare. bytes.Equal은 첫 불일치에서 멈추므로 일치한 바이트 수가 시간에 반영된다.

에러도 하나뿐이다.

examples/10-web-frameworks/08-practice/api/store.go
// ErrBadCredentials는 이메일이 없거나 비밀번호가 틀렸을 때다.
// 두 경우를 구분해 주면 계정 존재 여부가 새어 나간다.
var ErrBadCredentials = errors.New("api: 이메일 또는 비밀번호가 올바르지 않다")

소유자 범위 — 403이 아니라 404다

examples/10-web-frameworks/08-practice/api/store.go
// ErrNotFound는 없는 ID를 가리켰을 때다.
//
// 남의 할 일에 접근했을 때도 이 에러를 쓴다. 403을 주면 "그 ID는 존재한다"는
// 사실이 새어 나간다. 존재를 숨겨야 하는 자원에는 404가 맞다.
var ErrNotFound = errors.New("api: 할 일 없음")

이 판단이 저장소 계층에 박혀 있다. 소유자 확인을 핸들러가 아니라 저장소가 한다.

examples/10-web-frameworks/08-practice/api/store.go
// Get은 소유자를 확인하며 하나를 찾는다.
func (s *TaskStore) Get(ownerID string, id int) (Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()

t, ok := s.tasks[id]
if !ok || t.OwnerID != ownerID {
return Task{}, ErrNotFound
}
return t, nil
}

!ok || t.OwnerID != ownerID가 한 줄에 있는 것이 핵심이다. 두 조건을 따로 두면 언젠가 한쪽을 빼먹는다. 시그니처가 ownerID를 요구하므로 소유자 확인 없이 호출하는 것 자체가 불가능하다 — 타입이 규칙을 강제한다.

examples/10-web-frameworks/08-practice/api/api_test.go
// TestTasksAreScopedToOwner는 남의 할 일이 보이지도 만져지지도 않는지 본다.
// 이 테스트가 없으면 인증만 있고 인가는 없는 API가 된다.
func TestTasksAreScopedToOwner(t *testing.T) {
hs := newHarness(t, fixedClock())

memberToken, _ := hs.login(t, "gopher@example.com", "hunter2!!")
adminToken, _ := hs.login(t, "admin@example.com", "hunter2!!")

rec, _ := hs.do(t, request{
method: http.MethodPost, target: "/tasks",
body: `{"title":"내 할 일"}`, token: memberToken,
})
if rec.Code != http.StatusCreated {
t.Fatalf("생성 status = %d", rec.Code)
}

// 관리자도 남의 할 일을 일반 경로로는 못 본다.
rec, body := hs.do(t, request{method: http.MethodGet, target: "/tasks/1", token: adminToken})
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 (403이면 존재가 새어 나간다)", rec.Code)
}
if strings.Contains(body, "forbidden") {
t.Errorf("403이 나왔다: %s", body)
}

// 목록에도 안 나온다.
_, body = hs.do(t, request{method: http.MethodGet, target: "/tasks", token: adminToken})
if !strings.Contains(body, `"count":0`) {
t.Errorf("남의 할 일이 목록에 보인다: %s", body)
}
}

인증 미들웨어

examples/10-web-frameworks/08-practice/api/auth.go
// RequireAuth는 Authorization 헤더의 Bearer 토큰을 검증한다.
func (a *API) RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
raw, ok := bearerToken(c.GetHeader("Authorization"))
if !ok {
// 401에는 WWW-Authenticate를 붙이는 것이 RFC 9110의 요구다.
c.Header("WWW-Authenticate", `Bearer realm="tasks-api"`)
a.abort(c, http.StatusUnauthorized, "unauthorized", "인증이 필요하다", nil)
return
}

claims, err := a.verifier.Verify(raw, token.KindAccess)
if err != nil {
// 만료와 그 밖의 실패를 갈라 준다. 클라이언트가 재발급을 시도할지
// 다시 로그인시킬지 판단할 수 있어야 한다.
code, msg := "invalid_token", "토큰이 올바르지 않다"
if errors.Is(err, jwt.ErrTokenExpired) {
code, msg = "token_expired", "토큰이 만료됐다"
}
c.Header("WWW-Authenticate", `Bearer realm="tasks-api", error="`+code+`"`)
a.abort(c, http.StatusUnauthorized, code, msg, nil)
return
}

// 토큰이 유효해도 사용자가 삭제됐을 수 있다.
// 토큰만 믿으면 탈퇴한 계정이 만료 전까지 계속 접근한다.
u, err := a.users.ByID(claims.Subject)
if err != nil {
a.abort(c, http.StatusUnauthorized, "invalid_token", "토큰이 올바르지 않다", nil)
return
}

// 역할은 토큰이 아니라 저장소에서 읽는다. 그래야 권한 변경이
// 토큰 만료를 기다리지 않고 즉시 반영된다.
p := Principal{UserID: u.ID, Role: u.Role}
c.Set(principalKey, p)
c.Request = c.Request.WithContext(
context.WithValue(c.Request.Context(), principalCtxKey, p))

c.Next()
}
}

네 가지 판단이 들어 있다.

  1. a.abortAbortWithStatusJSON이다. 10-4에서 본 대로, c.JSON만 쓰면 인증 실패인데도 핸들러가 돈다.
  2. WWW-Authenticate 헤더. 401 응답에 인증 방식을 알려 주는 것이 RFC 9110의 요구다. 만료인 경우 error="token_expired"를 실어 클라이언트가 분기하게 한다.
  3. 토큰이 유효해도 사용자를 조회한다. 10-7에서 말한 폐기 문제의 답이다. 탈퇴한 계정이 즉시 막힌다.
  4. 역할을 토큰이 아니라 저장소에서 읽는다. 강등이 즉시 반영된다. 토큰의 rol 클레임은 결국 힌트일 뿐이다.

Bearer 파싱은 대소문자를 구분하지 않는다.

examples/10-web-frameworks/08-practice/api/auth.go
// bearerToken은 "Bearer xxx"에서 xxx를 꺼낸다.
// 스킴 비교는 대소문자를 구분하지 않는다(RFC 9110).
func bearerToken(header string) (string, bool) {
scheme, rest, found := strings.Cut(header, " ")
if !found || !strings.EqualFold(scheme, "Bearer") {
return "", false
}
rest = strings.TrimSpace(rest)
if rest == "" {
return "", false
}
return rest, true
}

인가 — 401과 403을 가른다

examples/10-web-frameworks/08-practice/api/auth.go
// RequireRole은 역할을 요구한다. RequireAuth 뒤에 놓아야 한다.
func (a *API) RequireRole(role string) gin.HandlerFunc {
return func(c *gin.Context) {
if principalOf(c).Role != role {
// 인증은 됐지만 권한이 없다. 401이 아니라 403이다.
a.abort(c, http.StatusForbidden, "forbidden", "권한이 없다", nil)
return
}
c.Next()
}
}
상태 코드클라이언트가 할 일
401네가 누구인지 모르겠다로그인하거나 토큰을 갱신한다
403네가 누구인지는 알겠는데 안 된다재시도해도 소용없다

소유자 범위는 404, 역할 부족은 403이다. 둘의 차이는 "자원의 존재를 숨겨야 하는가"다. /admin/tasks/1은 관리자 전용 경로임이 이미 공개되어 있으므로 403이 정보를 흘리지 않는다.

라우팅 조립

examples/10-web-frameworks/08-practice/api/api.go
auth := r.Group("/auth")
{
auth.POST("/login", a.login)
auth.POST("/refresh", a.refresh)
}

// 여기부터는 전부 인증이 필요하다.
secured := r.Group("/")
secured.Use(a.RequireAuth())
{
secured.GET("/me", a.me)
secured.GET("/tasks", a.list)
secured.POST("/tasks", a.create)
secured.GET("/tasks/:id", a.get)
secured.PATCH("/tasks/:id", a.patch)
secured.DELETE("/tasks/:id", a.delete)

// 관리자 전용. RequireAuth 뒤에 RequireRole을 겹친다.
secured.DELETE("/admin/tasks/:id", a.RequireRole("admin"), a.adminDelete)
}

보호가 필요한 라우트를 그룹으로 묶는 것이 핵심이다. 라우트마다 미들웨어를 붙이면 언젠가 하나를 빼먹는다. secured 그룹 안에 넣는 것이 기본이고, 공개 라우트가 예외가 되어야 한다.

RequireRole은 라우트 단위로 얹는다 — 그룹을 하나 더 만들 수도 있지만 관리자 라우트가 하나뿐이라 이쪽이 짧다.

검증 등록은 한 번만

examples/10-web-frameworks/08-practice/api/validate.go
// registerOnce는 전역 등록이 한 번만 일어나게 한다.
// binding.Validator는 gin 패키지 수준의 전역이고, Handler는 테스트에서
// 여러 번 불린다.
var registerOnce sync.Once

func registerValidators() {
registerOnce.Do(func() {
// 알 수 없는 JSON 필드를 거부한다. 9-8은 요청마다
// dec.DisallowUnknownFields()로 정했지만, Gin에서는 이것이
// 패키지 전역 스위치다 — 엔드포인트별로 다르게 할 수 없다.
gin.EnableJsonDecoderDisallowUnknownFields()

10-3에서 경고한 전역 등록 문제의 실무적 답이다. sync.Once(7-5)로 감싼다.

gin.EnableJsonDecoderDisallowUnknownFields()는 9-8과 동작을 맞추기 위해 필수다. 없으면 {"title":"a","titel":"오타"}201로 통과한다 — 오타 필드가 조용히 무시되고 사용자는 자기 입력이 반영된 줄 안다. 이 강의를 쓰는 동안 실제로 그랬고, 일치 테스트가 잡았다.

토큰 쌍 발급

examples/10-web-frameworks/08-practice/api/auth.go
func (a *API) issuePair(u *User) (tokenPair, error) {
access, err := a.issuer.Access(u.ID, u.Role)
if err != nil {
return tokenPair{}, err
}
refresh, err := a.issuer.Refresh(u.ID)
if err != nil {
return tokenPair{}, err
}
return tokenPair{
AccessToken: access,
RefreshToken: refresh,
TokenType: "Bearer",
ExpiresIn: int(a.accessTTL.Seconds()),
}, nil
}

token_typeexpires_in은 OAuth 2.0 토큰 응답의 관례다. 클라이언트가 만료 시각을 계산해 미리 갱신할 수 있게 한다.

갱신은 리프레시 토큰도 새로 준다.

examples/10-web-frameworks/08-practice/api/auth.go
// 갱신할 때마다 리프레시 토큰도 새로 준다(rotation).
// 실제 서비스라면 여기서 이전 jti를 폐기 목록에 올려야 재사용을 막는다.
pair, err := a.issuePair(u)

테스트 하네스

examples/10-web-frameworks/08-practice/api/api_test.go
// newHarness는 사용자 둘(일반, 관리자)이 들어 있는 API를 만든다.
// clock을 인자로 받아 만료 테스트가 시간을 밀 수 있게 한다.
func newHarness(t *testing.T, clock func() time.Time) harness {
t.Helper()

users := api.NewUserStore()
if err := users.Add("u-1", "gopher@example.com", "hunter2!!", "member", []byte("salt-1")); err != nil {
t.Fatalf("사용자 등록: %v", err)
}
if err := users.Add("u-2", "admin@example.com", "hunter2!!", "admin", []byte("salt-2")); err != nil {
t.Fatalf("사용자 등록: %v", err)
}

시계를 함수로 받으므로 테스트 안에서 변수를 바꿔 시간을 밀 수 있다.

examples/10-web-frameworks/08-practice/api/api_test.go
// TestExpiredAccessTokenAndRefresh는 만료와 갱신 흐름을 함께 확인한다.
func TestExpiredAccessTokenAndRefresh(t *testing.T) {
now := fixedNow
clock := func() time.Time { return now }
hs := newHarness(t, clock)

access, refresh := hs.login(t, "gopher@example.com", "hunter2!!")

// 16분 뒤로 시계를 민다. AccessTTL은 15분이다.
now = fixedNow.Add(16 * time.Minute)

rec, body := hs.do(t, request{method: http.MethodGet, target: "/me", token: access})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if !strings.Contains(body, `"code":"token_expired"`) {
t.Errorf("만료를 구분해 주지 않는다: %s", body)
}
if got := rec.Header().Get("WWW-Authenticate"); !strings.Contains(got, `error="token_expired"`) {
t.Errorf("WWW-Authenticate = %q", got)
}

// 리프레시 토큰은 아직 살아 있다.
rec, body = hs.do(t, request{
method: http.MethodPost, target: "/auth/refresh",
body: `{"refresh_token":"` + refresh + `"}`,
})
if rec.Code != http.StatusOK {
t.Fatalf("갱신 status = %d body = %s", rec.Code, body)
}
var pair struct {
AccessToken string `json:"access_token"`
}
if err := json.Unmarshal([]byte(body), &pair); err != nil {
t.Fatalf("파싱: %v", err)
}
if pair.AccessToken == access {
t.Error("갱신했는데 같은 액세스 토큰이 나왔다")
}

rec, _ = hs.do(t, request{method: http.MethodGet, target: "/me", token: pair.AccessToken})
if rec.Code != http.StatusOK {
t.Errorf("새 토큰으로 status = %d", rec.Code)
}
}

time.Sleep(16 * time.Minute)가 아니다. 시계를 주입했기 때문에 즉시 끝난다.

로그에 비밀이 남지 않는지도 테스트한다.

examples/10-web-frameworks/08-practice/api/api_test.go
// TestPasswordIsNeverLogged는 로그에 자격 증명이 남지 않는지 확인한다.
func TestPasswordIsNeverLogged(t *testing.T) {
hs := newHarness(t, fixedClock())
access, refresh := hs.login(t, "gopher@example.com", "hunter2!!")

hs.do(t, request{method: http.MethodGet, target: "/me", token: access})

out := hs.logs.String()
for _, secret := range []string{"hunter2!!", access, refresh} {
if strings.Contains(out, secret) {
t.Errorf("로그에 비밀이 남았다:\n%s", out)
}
}
}

동시성 테스트에서 로그 버퍼가 경합한다는 것도 -race가 잡아 줬다.

examples/10-web-frameworks/08-practice/api/api_test.go
// safeBuffer는 동시 요청이 같은 로그에 쓰는 것을 견딘다.
// strings.Builder는 동시 쓰기에 안전하지 않아 -race가 바로 잡아낸다.
type safeBuffer struct {
mu sync.Mutex
buf strings.Builder
}

실행

go run ./08-practice
--- 요청/응답 ---
GET /healthz 익명 인증 없이 통과 → 200 {"status":"ok"}
GET /tasks 익명 토큰 없음 → 401 {"code":"unauthorized","message":"인증이 필요하다","request_id":"4"}
GET /tasks member 빈 목록 → 200 {"count":0,"items":[]}
POST /tasks member 생성 → 201 {"id":1,"owner_id":"u-1","title":"우유 사기","status":"todo","created_at":"2026-08-12T09:00:00Z","updated_at":"2026-08-12T09:00:00Z"}
POST /tasks member 검증 실패 → 422 {"code":"validation","message":"입력이 올바르지 않다","fields":{"title":"필수 항목이다"},"request_id":"7"}
POST /tasks member 모르는 필드 → 400 {"code":"bad_json","message":"모르는 필드 \"titel\"","request_id":"8"}
POST /tasks admin 다른 사용자가 생성 → 201 {"id":2,"owner_id":"u-2","title":"관리자 할 일","status":"todo","created_at":"2026-08-12T09:00:00Z","updated_at":"2026-08-12T09:00:00Z"}
GET /tasks member 내 것만 보인다 → 200 {"count":1,"items":[{"id":1,"owner_id":"u-1","title":"우유 사기","status":"todo","created_at":"2026-08-12T09:00:00Z","updated_at":"2026-08-12T09:00:00Z"}]}
GET /tasks/2 member 남의 것은 404 → 404 {"code":"not_found","message":"할 일 2 없음","request_id":"11"}
PATCH /tasks/1 member 수정 → 200 {"id":1,"owner_id":"u-1","title":"우유 사기","status":"done","created_at":"2026-08-12T09:00:00Z","updated_at":"2026-08-12T09:00:00Z"}
DELETE /admin/tasks/1 member 권한 없음 403 → 403 {"code":"forbidden","message":"권한이 없다","request_id":"13"}
DELETE /admin/tasks/1 admin 관리자 삭제 → 204
GET /tasks member 삭제 확인 → 200 {"count":0,"items":[]}
PUT /tasks/2 member 허용되지 않은 메서드 → 405 {"code":"method_not_allowed","message":"허용되지 않은 메서드","request_id":"16"}
GET /nope member 없는 경로 → 404 {"code":"not_found","message":"경로 없음","request_id":"17"}

--- 접근 로그 (토큰과 비밀번호는 남지 않는다) ---
POST /auth/login 200 id=1
POST /auth/login 200 id=2
GET /healthz 200 id=3
GET /tasks 401 id=4
GET /tasks 200 id=5
POST /tasks 201 id=6
POST /tasks 422 id=7
POST /tasks 400 id=8
POST /tasks 201 id=9
GET /tasks 200 id=10
GET /tasks/2 404 id=11
PATCH /tasks/1 200 id=12
DELETE /admin/tasks/1 403 id=13
DELETE /admin/tasks/1 204 id=14
GET /tasks 200 id=15
PUT /tasks/2 405 id=16
GET /nope 404 id=17

시계와 솔트를 고정했으므로 실행마다 바이트 단위로 같다.

-serve를 주면 실제 서버가 뜬다. 서버 조립 코드는 9-8과 완전히 같다 — Gin 엔진이 http.Handler이기 때문이다.

examples/10-web-frameworks/08-practice/main.go
// listenAndServe는 9-8과 같은 방식으로 서버를 띄우고 정상 종료한다.
// Gin 엔진이 http.Handler이므로 여기 코드는 9-8과 완전히 같다.
func listenAndServe(h http.Handler, addr string, out io.Writer) error {
srv := &http.Server{
Handler: h,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("리스닝: %w", err)
}
fmt.Fprintf(out, "listening on http://%s (Ctrl+C로 종료)\n", ln.Addr())

CORS와 Authorization 헤더

10-4의 CORS를 그대로 얹되 한 가지를 더 챙긴다.

examples/10-web-frameworks/08-practice/api/api.go
// cors는 10-4에서 만든 것과 같은 정책이다.
// Authorization 헤더를 허용 목록에 넣지 않으면 브라우저가 토큰을 못 보낸다.
examples/10-web-frameworks/08-practice/api/api_test.go
func TestCORSPreflightAllowsAuthorizationHeader(t *testing.T) {
hs := newHarness(t, fixedClock())

rec, _ := hs.do(t, request{
method: http.MethodOptions, target: "/tasks",
header: map[string]string{
"Origin": "https://app.example.com",
"Access-Control-Request-Method": "POST",
},
})
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, "Authorization") {
t.Errorf("Allow-Headers = %q — Authorization이 없으면 브라우저가 토큰을 못 보낸다", got)
}
}

프리플라이트에서 Authorization을 허락하지 않으면, 브라우저는 본 요청을 보내지도 않는다. 서버 로그에는 아무것도 안 남고 프런트엔드 콘솔에만 CORS 에러가 뜬다 — 디버깅이 가장 어려운 부류의 실수다.

여기서 멈춘 것들

빠진 것어디서
구조적 로깅 (log/slog)파트 12-2
설정 관리 — 비밀 키를 환경 변수에서파트 12-3
완전한 graceful shutdown파트 12-8
진짜 데이터베이스파트 11
리프레시 토큰 폐기 목록10-7 연습문제 2
레이트리밋 — 로그인 무차별 대입 방어10-4 연습문제 3
회원가입, 비밀번호 변경이 챕터 연습문제

비밀 키를 코드에 박아 둔 것이 가장 큰 미완성이다. 데모 출력을 결정적으로 만들기 위한 선택이고, 주석에 그렇게 적어 뒀다.

흔한 실수

1. 인증 미들웨어에서 c.Abort를 안 쓴다

토큰이 없는데 핸들러가 돈다. 10-4의 그 실수가 여기서는 보안 사고다.

2. 라우트마다 인증 미들웨어를 붙인다

언젠가 하나를 빼먹는다. 그룹으로 묶고 공개 라우트를 예외로 둔다.

3. 역할을 토큰에서만 읽는다

강등된 사용자가 토큰 만료까지 옛 권한을 유지한다.

4. 남의 자원 접근에 403을 준다

ID의 존재 여부가 새어 나간다. 존재를 숨겨야 하면 404다.

5. 소유자 확인을 핸들러에서 한다

저장소 시그니처가 ownerID를 요구하게 만들면 빼먹을 수 없다.

6. 로그인 실패를 "없는 계정"과 "틀린 비밀번호"로 나눈다

계정 열거 공격이 가능해진다. 응답 시간까지 같게 맞춰야 한다.

7. 비밀번호를 sha256 한 번으로 해싱한다

GPU로 초당 수십억 번 시도된다. PBKDF2, bcrypt, argon2 중 하나를 쓴다.

8. 해시 비교에 bytes.Equal을 쓴다

subtle.ConstantTimeCompare를 쓴다.

9. 토큰이나 비밀번호를 로그에 남긴다

로그 접근 권한이 곧 계정 접근 권한이 된다.

10. CORS AllowHeadersAuthorization을 빼먹는다

브라우저가 본 요청을 아예 안 보낸다. 서버 로그에 흔적이 없다.

11. gin.EnableJsonDecoderDisallowUnknownFields()를 빼먹는다

오타 필드가 조용히 무시되고 사용자는 반영된 줄 안다.

12. 만료와 서명 오류를 같은 응답으로 낸다

클라이언트가 갱신을 시도할지 로그아웃시킬지 판단하지 못한다.

정리

  • 9-8의 에러 형식과 400/422 규약을 그대로 유지했다. 프레임워크가 바뀌어도 클라이언트 계약은 안 바뀌어야 한다.
  • 인증은 그룹으로 건다. 라우트마다 붙이면 언젠가 빠진다.
  • 토큰이 유효해도 사용자를 조회하고, 역할은 저장소에서 읽는다. 탈퇴와 강등이 즉시 반영된다. JWT의 폐기 문제에 대한 실무적 답이다.
  • 401은 "누구인지 모르겠다", 403은 "안 된다", 404는 "존재를 숨긴다". 소유자 범위 위반은 404다.
  • 소유자 확인을 저장소 시그니처로 강제한다. Get(ownerID, id)는 소유자 없이 부를 수 없다.
  • 비밀번호는 PBKDF2 + 솔트 + 상수 시간 비교. crypto/pbkdf2가 Go 1.24부터 표준 라이브러리에 있다.
  • 로그인 실패는 한 가지 에러로만 답하고, 없는 계정에도 해시를 돌린다.
  • 시계를 주입하면 만료 테스트가 즉시 끝나고 데모 출력이 결정적이 된다.
  • gin.EnableJsonDecoderDisallowUnknownFields()Authorization AllowHeaders — 둘 다 빼먹으면 조용히 잘못 동작한다.

연습문제

  1. POST /auth/register를 추가해 보자. 이메일 중복은 몇 번이어야 하는가 — 409? 422? 그 판단을 테스트로 못 박고, 등록 직후 자동 로그인시킬지도 결정한다. 솔트는 어떻게 만드는가? (힌트: 9-2의 crypto/rand.Text)

  2. 리프레시 토큰 회전을 제대로 만들어 보자. 갱신할 때 이전 리프레시 토큰의 jti를 폐기하고, 폐기된 토큰이 다시 오면 그 사용자의 모든 토큰을 무효화한다 (토큰 탈취의 신호이므로). UserStore에 무엇을 더 넣어야 하는가?

  3. PATCH /tasks/:id를 관리자가 남의 것도 수정할 수 있게 바꿔 보자. RequireRole을 라우트에 붙일 수는 없다 — 일반 사용자도 자기 것은 수정해야 한다. 저장소 시그니처를 어떻게 바꾸면 "소유자이거나 관리자"를 표현하면서도 핸들러가 확인을 빼먹을 수 없게 되는가?

  4. 9-8 연습문제 3이 남겨 둔 과제를 여기서 이어 보자. TaskStore를 인터페이스로 추출하고 api.Config가 그 인터페이스를 받게 한다. 인터페이스에 메서드가 몇 개 필요한가? 그 인터페이스는 어느 파일에 있어야 하는가(6-5)? 파트 11이 그 자리에 진짜 DB 구현을 넣는다.