개발

Express 앱에 passport와 Auth0로 계정 기능 추가하기

마크맨 2019. 11. 24. 22:21

들어가며

회사 내부 툴 개발 중에 Express, Typescript, Angular 프로젝트에 Auth0를 연결하였는데 하면서 배운 내용과 함께 삽질한 후기를 작성해본다.

 

Auth0 기본 정보

- 홈페이지 : https://auth0.com 

 

Never Compromise on Identity. - Auth0

Auth0 is the solution you need for web, mobile, IoT, and internal applications. Loved by developers and trusted by enterprises.

auth0.com

Auth0는 무료 또는 유료로 로그인 및 계정 관리를 꽤 쉽게 도와주는 서비스이다. 무료 플랜을 사용한다 해도 기본적인 기능들이 다 구현이 되지만, Role 관리 같은 기능은 유료 플랜에 들어가있다.

 

무료 플랜을 사용하면서 역할 관리를 하려면 추가적으로 custom extension을 사용하거나 따로 db를 이용하는 방법이 있고, 만약 따로 db를 사용한다면 (아마도) 관리 이중화로 인해 조금 불편하지 않을까 싶다.

 

사용하려면 계정을 만들어야 한다. 계정을 만들고 Dashboard에서 새 Application을 만들면 기본적인 준비는 끝난다. 

 

Auth0 인증 방식

Auth0는 다양한 방식의 로그인을 지원한다. 

  • Native / Mobile App 
  • SPA
  • Regular Web App
  • Backend API

처음에 SPA document를 따라하면서 이것저것 reference를 뒤져가며 같이 했는데, 그러다보니 백엔드에서도 로그인을 요청하고, 프론트에서도 로그인을 요청하는, 로그인을 두번 요청하게 되는 괴상한 구조가 되어서 결국에는 그냥 Regular Web App > Node (https://auth0.com/docs/quickstart/webapp/nodejs) 방식으로 구현하였다. 

 

이 과정에서 passport-auth0, express-session을 사용하여 조금 더 편하게 구현하였다.

 

NodeJS에서 위 패키지와 함께 사용 시 기본적인 동작 방식은 다음과 같다.

 

<로그인>

  1. 프론트(Angular SPA)에서 백엔드에 'GET /login'을 한다.
  2. passport-auth0에서 프론트에게 'Auth0 로그인 화면'으로 리디렉팅 한다. 이 때 앱 정보와 callback URL을 같이 던져준다.
  3. 사용자에게 로그인 화면이 뜬다(Auth0 대시보드에서 생성한 app의 url로). 로그인을 진행한다.
  4. Auth0 에서 validation을 하고나서 accessToken, refreshToken, userdata를 2번의 callback URL로 던져준다.
  5. 백엔드에서 해당 정보를 받아서 passport가 request object에 주입한 req.login()을 실행하고, session을 만든다
  6. 프론트로 쿠키 정보를 내려주고, '/'로 리디렉트 한다.
  7. 프론트는 새로고침 되면서 미리 써놓은 대로 'GET /session'을 하고
  8. 백엔드는 cookie를 보고 ok해준다. 정해놓은 session 정보를 내려준다

<로그아웃>

  1. 프론트에서 'GET /logout'한다.
  2. passport가 request object에 살짝 넣어놓은 req.logout() 함수를 호출한다.
  3. Auth0 서버에 로그아웃 요청을 보낸다.
  4. 백엔드에서 '/'로 리디렉트 한다.
  5. 프론트는 200받고 나서 '/'로 리디렉트 된다. 

계정 정보랑 validation은 Auth0가 알아서 해주고, 백엔드 세션 관리는 passport가 해주므로, 프론트에서 세션관리랑 백엔드에 API를 파주면 빠르게 로그인을 구현할 수 있다. 

 

시작

(소스는 https://github.com/cooco119/express-auth0-example)

express 웹 서버, 그리고 vue.js 프론트를 만들어 보았다.

 

디렉토리 구조는 다음과 같다.

express-auth0-example/
	backend/
		dist/
		public/
		src/
		package.json
	frontend/
		dist/
		src/
		package.json

 

기본 앱을 띄워보자

Express, Vue, Typescript를 사용해서 기본 앱을 띄워보자. 

// backend/src/index.ts

import express from "express";
import bodyparser from "body-parser";
import cors from "cors";
import path from "path";

const app = express();
app.use(cors());
app.use(bodyparser.json());
app.use(express.static(path.join(__dirname, "../public")));

app.listen(5000, () => {
    console.log("Server listening to 5000 port");
});

 

 

프론트도 vue cli를 사용해 잘 init 해주고 vue.config.js에 빌드 경로만 설정해주자

// frontend/vue.config.js

module.exports = {
  outputDir: "../backend/public"
}

npm run build 하고 backend에서 npm run start해주면 잘 뜨는지 확인하자.

 

Auth0를 설정해보자

Auth0 홈페이지에서 앱을 하나 만들어보자.

들어가보면 앱 파라미터를 확인할 수 있다.

이렇게 설정해주자

 

CORS랑 callback 등 허용 가능한 url을 설정해 주고, Type은 Regular Web Application으로 설정하였다.

localhost에서만 할거니까 이렇게만 해주자.

 

백엔드에 passport 설정하기

passport랑 auth0 연동을 도와주는 passport-auth0를 설치한다.(http://www.passportjs.org/packages/passport-auth0/)

npm install passport passport-auth0 express-session

 

// backend/src/index.ts
import expressSession from "express-session";
import passport from "passport";
import Auth0Strategy from "passport-auth0";

...

const sess = {
    secret: "save-martha",		
    cookie: {},
    resave: false,
    saveUninitialized: true
}
app.use(expressSession(sess));

const strategy = new Auth0Strategy.Strategy({
    domain: "DOMAIN_FOR_YOUR_AUTH0_APP",		// auth0에서 app의 config 정보들
    clientID: "YOUR_AUTH0_CLIENT_ID",
    clientSecret: "YOUR_AUTH0_CLIENT_SECRET",
    callbackURL: "http://localhost:5000/callback",
    scopeSeparator: "openid email profile",		// 중요
},
    (accessToken, refreshToken, extraParams, profile, done) => {
        return done(null, profile);
    });

passport.use(strategy);

app.use(passport.initialize());
app.use(passport.session());

...

express-session과 passport-auth0 설정을 위와 같이 해준다. 

 

그리고 /login, /logout, /callback, /session API들을 만들어주자. 여기를 통해 프론트와 Auth0 서버가 접근할 수 있다.

// backend/src/index.ts

...

app.get("/login", passport.authenticate('auth0', {
    scope: 'openid email profile'
}), function (req, res) {
    res.redirect('/');
});

app.get("/callback", (req, res, next) => {
    passport.authenticate("auth0", function (err, user, info) {
        if (err) { return next(err); }
        if (!user) { return res.redirect("/login"); }
        req.logIn(user, function (err) {
            if (err) { return next(err); }
            delete req.session.returnTo;
            res.redirect("/");
        });
    })(req, res, next);
});

app.get("/logout", (req, res, next) => {
    req.logOut();
    let returnTo = req.protocol + "://" + req.hostname;
    const port = req.connection.localPort;
    if (port !== undefined && port !== 80 && port !== 443) {
        returnTo += ":" + port;
    }
    const logoutURL = new url.URL(
        util.format("https://%s/v2/logout", "YOUR_CLIENT_DOMAIN")
    );
    const searchString = querystring.stringify({
        client_id: "YOUR_CLIENT_ID",
        returnTo,
    });
    logoutURL.search = searchString;

    res.redirect(logoutURL.toString());
});

app.get("/session", (req, res, next) => {
    if (!req.user) {
        return res.json({ loggedIn: false, user: null });
    }
    res.json({ loggedIn: true, user: req.user });
});

...

/login 내에 scope 설정은 꼭 해준다. 안해주면 user 정보를 받을 수 없다. (https://auth0.com/docs/scopes/current)

 

/session은 프론트가 로드 될 때 마다 호출하게 해서, req.user가 있으면(session이 유효한 상태) loggedIn: true와 유저 정보를 보내주고, 아니라면 안보내줘서 프론트를 통제할 수 있게 해줄 수 있도록 작성하였다.

 

 

이제 프론트에서 로그인 버튼을 만들고, user data를 보여줄 수 있도록 해보자.

Helloworld Component를 다음과 같이 변경한다.

 

<template>
  <div class="hello">
    <a v-if="!loggedIn" href="/login">Log in</a>
    <a v-if="loggedIn" href="/logout">Log out</a>
    <div v-if="!!user">
      <h3>Welcome {{ user.nickname }}!!</h3>
      <h3>Email: {{ user.emails[0].value }}</h3>
    </div>
  </div>
</template>

<script lang="ts">
import Vue from 'vue';
import axios from 'axios';

export default Vue.extend({
  name: 'HelloWorld',
  props: {
    msg: String,
  },
  data: () => ({
    loggedIn: false,
    user: null,
  }),
  async mounted() {
    await this.session();
  },
  methods: {
    async session() {
      const response = await axios.get("/session");
      this.loggedIn = response.data.loggedIn;
      this.user = response.data.user;
    },

  }
});
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
  margin: 40px 0 0;
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
</style>

mount 될 때마다 'GET /session'을 해서 로그인 정보를 불러오고, 해당 정보를 가지고 Log in / Log out 을 표기한다.

 

 

확인해보자..!

이제 프론트를 빌드하기 위해 npm run build를 하고,

백엔드를 npm run start로 켜고 나서

 

http://localhost:5000을 들어가보면,

 

로그인을 해보자.

잘뜬다!

여기서 계정 등록 하고 로그인을 하면 이제 아까 설정한 /callback으로 리디렉션 된다. 서버에서 처리한 다음에는

계정을 등록하면 앱을 사용하겠냐고 뜬다

이름과 이메일이 결과로 뜨게 된다. 물론 auth0 management api나 auth0 dashboard에서 직접 이름이나 정보를 수정할 수 있다.

 

로그아웃 또한 정상적으로 작동한다.

 

 

해당 과정을 크롬 디버거로 찍어보면 다음과 같다.

많이도 왔다갔다 했누

  1. 서버에 login을 날리면
  2. authorize 및 login을 우리의 Auth0 app domain으로 query-param과 함께 보낸다 (client-id, scope 등등)
  3. 이후 auth0에서 callback으로 다시 날리고, 
  4. callback에서는 '/'로 리디렉트 해주는데
  5. 리디렉트 되고 프론트는 GET /session을 날리고 로그인 정보를 받는다.
  6. logout을 누르니 Auth0로 logout 콜을 날리고 다시 '/'로 리디렉트

 

추가

아쉽지만 무료 플랜이기에 Role을 설정할 수 없다.

간단하게 DB에 계정 정보, role, permission등의 메타 정보를 직접 관리할 수도 있다.

위 /login을 작성할 때 중간에 DB에 해당 정보를 기록하고, /session을 콜 할때 DB에서 메타정보를 읽어서 프론트에 내려주는 방법이 있을 수 있다.