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