Web/개발

[웹 서비스] OAuth 2.0

로파이 2024. 12. 28. 19:02

OAuth 2.0

외부 웹 서비스 계정을 이용하여 해당 서비스 권한을 간접적으로 획득하여 나의 웹 서버에서 활용하고자 할 때,

외부 웹 서비스에 나의 웹 서버를 인증하는 프로토콜

 

나의 서비스가 유저의 구글, 야후, 카카오 등 대표 가능한 웹 서비스의 이메일 주소를 이용하고자 한다.

유저의 이메일 주소를 알 수 있다면 나의 웹 사이트에서는 따로 이메일 가입을 할 필요 없이 해당 외부 웹 서비스 계정 정보를 이용하여 유저를 특정하고 계정으로 사용할 수 있을 것 이다.

 

참여자

 

서비스를 이용하고자 하는 유저 (resource owner) : 내 서비스가 참조하고자 하는 자원 (이메일 주소)를 소유한다.

내 서비스 (client) : 유저의 정보를 활용하고자하는 주체. 웹 서비스가 될 수도 있고 앱 어플리케이션 자체가 요구할 수도 있다.

인증 서버 (authorization server) : 내 서비스를 등록하고 유저의 요청이 왔을 때, 나의 서비스에 의해 요청된 것인 지 인증하고 나의 서비스에 접근 토큰(access token)을 제공한다.

자원 서버 (resource server): 외부 서비스 중 실제 유저의 정보를 저장하고 있는 서버이다. 나의 서비스는 제공 받은 접근 토큰을 사용해 해당 서버에 직접 API를 요청한다.

 

간단한 절차

1. 서비스 등록

OAuth2.0을 사용하여 인증을 하기 위해 인증 서비스에 내 서비스를 등록한다.

외부 서비스에는 유저가 계정 인증을 완료했을 때, 획득한 접근 토큰을 가지고 호출할 콜백 주소 redirect-uri를 등록한다.

외부 서비스는 내 서비스를 등록하고 인증 서버의 클라이언트로서 사용할 ClientID와 Client Secret을 제공받는다.

이는 외부 인증 서버로의 ID와 패스워드 역할을 하고 내 서비스는 이를 config 등으로 관리해둔다.

 

예시) Google Auth

구글에 내 서비스를 등록하면, ClientID와 Secret을 확인할 수 있다. Secret은 응답 내용 등에서 유저에 직접 노출되지 않도록 주의한다.

 

2. 인증 코드 요청


이제 라이브 중 인 내 서비스가 유저 외부 계정 정보를 이용하고자 한다.

유저는 외부 서비스를 통한 자신의 정보를 제공하기 위해 내 서비스로 OAuthURL를 요구한다.

내 서비스는 유저에게 외부 서비스 계정 로그인 웹 페이지로 연결되는 OAuthURL 주소를 제공한다.

이 떄 내 서비스는 "scope" 허락 받을 유저의 정보에 대한 범위를 의미하는 scope를 지정해서 OAuthURL을 만든다.

scope는 이메일 주소, 캘린더, 프로필 등 자원에 대한 접근 범위를 지정한다. 

 

웹 서비스라면 OAuthURL로 리다이렉션을 하여 로그인 페이지가 바로 보이게 하거나 앱이라면 Provider의 웹 서비스 앱을 열어 리다이렉션이 될 수도 있다.

이 때 유저가 요청하는 주소의 형식은 대략 다음과 같다.

https://${auth_server_address}?&client_id=${clientID}&redirect_uri=${redirectURL}?scope=${scope}

 

유저가 인증 서버를 통해 계정 인증을 완료하면, 인증 서버는 해당 redirect_uri와 client_id가 등록된 서비스인 지 확인한다.

유저가 요청한 정보와 내 서비스가 등록한 정보가 일치하지 않는다면, http code가 200이 아닌 에러 응답을 보낼 것이다.

 

인증 서버를 통해 정상 인증이 완료되면, 302 코드에 의해 Redirect이 다시 일어나며 응답 헤더에 적힌 Location으로 리다리에션된다.

이 때, 요청 주소에 인증 토큰 (인증 코드) code 파라미터 내용이 인증 서버에 첨부된 것을 확인할 수 있다.

*이 때, 접근 토큰을 직접 제공하지 않고 접근 토큰과 교환 가능한 인증 코드를 제공한다. 

 

3. 인증 코드 제공 및 접근 토큰 교환

유저의 인증 코드를 받은 이후 내 웹 서비스는 인증 코드, ClientID, Client Secret을 바탕으로 접근 토큰을 교환한다.

 

4. 접근 토큰을 사용한 유저 정보 조회 및 서비스 제공

성공적으로 접근 토큰을 교환하였다면, 내 웹 서비스는 허용된 범위 내에서 유저에 대한 정보를 조회할 수 있는 API를 resource server에 직접 요청할 수 있게 된다.

resource server가 제공하는 API를 통해 내 웹 서비스는 유저의 정보를 제공 받는다.

접근 토큰에는 유효 시간이 있기 때문에 필요하다면, 내 서비스는 접근 토큰을 갱신해야 한다. 

 

crsf token의 사용

OAuth 2.0 인증 과정을 보면, 인증 서버에 state 파리미터로 스트링값이 포함된 것을 볼 수 있다.

인증 서버로의 요청 시에 csrf 토큰 값을 state 파라미터로 전달하며, 인증 서버는 crsf 토큰을 다시 참조할 수 있도록 유저 리다이렉션 주소에 파라미터로 첨부한다.

 

Cross Site Request Forgery CSRF 크로스 사이트 요청 공격

 

공격자가 같은 겉모습을 가진 악의성 웹 사이트를 유저에게 사용하게 함으로써
특정 요청에 대한 내용을 변경하여 공격자의 의도대로 웹 서버로 요청하게 하는 행위 이다.

 

이 때, 웹 서비스는 자신이 제공한 웹 페이지가 현재 유저가 바라보는 웹 페이지와 일치하는 지 그리고 사용자의 요청이 자신의 웹 서비스 페이지로부터 온 것인지 확인할 필요가 있다.

 

웹 서비스는 CSRF 토큰을 발행하여 세션이나 보안 쿠키에 설정하여 사용자의 다음 요청에서 해당 토큰이 존재하고 일치하는 지 확인한다. 이에 따라 요청된 내용이 해당 토큰이 발행된 페이지에서 온 것 인지 검증한다.

 

 

예제) go 웹 서비스로 작성하여 google OAuth2.0을 사용하는 예제

더보기
package main

import (
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"log"
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/spf13/viper"

	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google"
)

type AuthProvider struct {
	Provider     string
	ClientId     string
	ClientSecret string
}

type AuthProviderConfig struct {
	Google AuthProvider
}

var (
	autoProviderConfig AuthProviderConfig
	oauth2Config       oauth2.Config
	oauth2State        string
)

func generateStateString() (string, error) {
	b := make([]byte, 32) // 32 bytes
	_, err := rand.Read(b)
	if err != nil {
		return "", err
	}
	return base64.URLEncoding.EncodeToString(b), nil
}

func initConfig() error {
	viper.SetConfigName("config")
	viper.AddConfigPath(".")
	viper.SetConfigType("yaml")

	return viper.ReadInConfig()
}

func loadAuthProvider() {
	if viper.IsSet("auth-provider.google") {
		clientId := viper.GetString("auth-provider.google.client-id")
		clientSecret := viper.GetString("auth-provider.google.client-secret")
		redirectUri := viper.GetString("auth-provider.google.redirect-uri")
		scopes := viper.GetStringSlice("auth-provider.google.scopes")

		autoProviderConfig.Google = AuthProvider{
			Provider:     "google",
			ClientId:     clientId,
			ClientSecret: clientSecret,
		}
		log.Printf("Google auth provider loaded with client id: %s", clientId)

		oauth2Config = oauth2.Config{
			ClientID:     clientId,
			ClientSecret: clientSecret,
			RedirectURL:  redirectUri,
			Scopes:       scopes,
			Endpoint:     google.Endpoint,
		}
	}
}

func getPing(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{"message": "server is healthy"})
}

func userAuthCallbackGoogle(c *gin.Context) {
	code := c.DefaultQuery("code", "")
	state := c.DefaultQuery("state", "")

	if code == "" || state == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "missing code or state"})
		return
	}

	// csrf attack check
	originalState, err := c.Cookie("oauth2_state")
	if err != nil || originalState == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "missing state cookie"})
		return
	}

	// exchange the code for a token
	token, err := oauth2Config.Exchange(c, code)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to exchange token"})
		return
	}

	// use the token to fetch user information
	client := oauth2Config.Client(c, token)
	resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch user info"})
		return
	}
	defer resp.Body.Close()

	// parse user data
	var user map[string]interface{}
	if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse user info"})
		return
	}

	c.JSON(http.StatusOK, gin.H{"email": user["email"], "name": user["name"]})
}

func userAuthGoogle(c *gin.Context) {
	// generate a random state string
	oauth2State, err := generateStateString()
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state"})
		return
	}

	// set cookie with state
	// use secure when in production as https is required
	c.SetCookie("oauth2_state", oauth2State, 3600, "", "", false, true)

	authURL := oauth2Config.AuthCodeURL(oauth2State, oauth2.AccessTypeOnline)

	log.Printf("Redirecting to %s", authURL)

	c.Redirect(http.StatusFound, authURL)
}

func main() {

	readConfigErr := initConfig()
	if readConfigErr != nil {
		log.Fatalf("Error reading config file, %s", readConfigErr)
	}

	loadAuthProvider()

	port := viper.GetString("server.port")
	mode := viper.GetString("server.mode")

	gin.SetMode(mode)

	router := gin.Default()
	router.GET("/ping", getPing)
	router.GET("/auth/google", userAuthGoogle)
	router.GET("/auth/google/callback", userAuthCallbackGoogle)

	router.Run("localhost:" + port)
}

main.go

server:
  port: "8080"

logging:
  level: "info"
  format: "json"

auth-provider:
  google:
    client-id: your-client-id
    client-secret: your-client-secret
    redirect-uri: "http://localhost:8080/auth/google/callback"
    scopes:
      - "openid"
      - "email"
      - "profile"

config.yml