Post

Spring Boot: “네이버 아이디로 로그인하기” 연동 - 스프링 시큐리티와 연결 (1)

깃허브에서 전체 코드 보기 - https://github.com/ayaysir/spring-boot-security-example-1

보다 개선된 네이버 로그인 - 스프링 부트(Spring Boot): 구글 로그인 연동 (스프링 부트 스타터의 oauth2-client) 이용 + 네이버 아이디로 로그인

이전글의 두 상황을 결합하여 네이버 아이디로 로그인(이하 네아로)을 스프링 시큐리티와 연결하는 예제입니다.

 

외부 소셜 로그인을 구현할 때 다음 상황이 있습니다.

  1. 기존에 사용자 계정이 존재하고, 네아로를 기존 로그인 체계로 연결
  2. 기존 사용자 계정과 연결하지 않고 네이버 아이디를 단독으로 사용

여기서는 1번을 다룹니다.

 

네아로 연결 시 구현 내용 및 주의사항들이 있습니다.

  1. 네아로 연동이 안되어 있다면, 네아로 연동하는 창을 띄운다. - 이 때, 로그인이 되어 있다면 기존 아이디를 네아로와 연동할 것인지 확인 여부를 물음
  2. 네아로 연동 안되어 있고 로그인이 되어 있지 않다면, 로그인 창(+회원가입 링크)으로 리다이렉트 - 기존에 회원아이디로 로그인했다면 네아로 연동 과정을 계속 진행 - 회원가입이 되지 않은 상태이며 이 회원 가입링크를 통해 가입한 경우 가입 완료하자마자 네아로 연동 과정 진행
  3. 네아로 연동이 되어 있다면 연동 정보를 통해 로그인 처리 - 현재 로그인한 계정과 네아로 연결된 로그인 계정이 다른 경우, 현재 계정을 로그아웃하고 그 연결된 계정으로 재로그인

내용이 굉장히 많아서 시리즈로 나눠서 연재하며 오늘은 3번만 구현하도록 하겠습니다.

 

1. 데이터베이스 구조

회원 테이블 (simple_users)

 

외부 로그인 연동 테이블(users_oauth)

username은 외래키로 회원 테이블의 키와 연결됩니다. provider는 제공사 이름으로, “naver”, “google” 등이 입력됩니다. unique_id에는 회원을 구분하는 고유값이 입력됩니다.

 

테스트를 위해 임의로 레코드 하나를 수작업으로 삽입했습니다. 나중에 연동 여부에 따라 컨트롤러에서 삽입되도록 변경할 예정입니다.

 

2. 컨트롤러, DAO 등 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
package com.springboot.security.controller;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.math.BigInteger;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.net.UnknownHostException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

import org.apache.tomcat.util.json.JSONParser;
import org.apache.tomcat.util.json.ParseException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import com.springboot.security.dao.SimpleUserDAO;
import com.springboot.security.util.UrlBuilder;

@Controller
public class LoginController {
  
  @Autowired SimpleUserDAO sud;

  private final String CLIENT_ID = "*************"; //애플리케이션 클라이언트 아이디값";
  private final String CLI_SECRET = "**************"; //애플리케이션 클라이언트 시크릿값";
  private final String REDIRECT_URI = "[네이버 개발자 센터에 등록된 콜백주소]";
  
  @RequestMapping("/login")
  public String loginForm(HttpSession session, Model model) throws UnsupportedEncodingException {
    String apiURL = getNaverOAuthURI(session);
    model.addAttribute("naverApiURL", apiURL);
    return "login-form";
  }

  /**
   * 로그인 폼을 거치지 않고 바로 로그인
   * @param username
   * @return
   */
  @RequestMapping("/loginWithoutForm/{username}")
  public String loginWithoutForm(@PathVariable(value="username") String username) {
    
    String roleStr = "ROLE_" + sud.getRolesByUsername(username).toUpperCase();

    List<GrantedAuthority> roles = new ArrayList<>(1);
    //String roleStr = username.equals("admin") ? "ROLE_ADMIN" : "ROLE_GUEST";
    roles.add(new SimpleGrantedAuthority(roleStr));

    User user = new User(username, "", roles);

    Authentication auth = new UsernamePasswordAuthenticationToken(user, null, roles);
    SecurityContextHolder.getContext().setAuthentication(auth);
    return "redirect:/";
  }

  /**
   * 현재 로그인한 사용자 정보 가져오기
   * @return
   */
  
   생략

  
  /**
   * getNaverOAuthURI (+ 세션의 "state" 속성에 값 부여)
   * @param session
   * @return
   * @throws UnsupportedEncodingException
   */
  private String getNaverOAuthURI(HttpSession session) throws UnsupportedEncodingException {
    String redirectURI = URLEncoder.encode(REDIRECT_URI, "UTF-8");

    SecureRandom random = new SecureRandom();
    String state = new BigInteger(130, random).toString();
    
    UrlBuilder ub = new UrlBuilder("https://nid.naver.com/oauth2.0/authorize");
    ub
      .add("response_type", "code")
      .add("client_id", CLIENT_ID)
      .add("redirect_uri", redirectURI)
      .add("state", state);
    String apiURL = ub.toString();
    session.setAttribute("state", state);
    
    return apiURL;
  }

  /**
   * 콜백 페이지 컨트롤러
   * @param session
   * @param request
   * @param model
   * @return
   * @throws IOException
   * @throws ParseException
   */
  @RequestMapping("/naver/callback1")
  public String naverCallback1(HttpSession session, HttpServletRequest request, Model model) throws IOException, ParseException {

    String code = request.getParameter("code");
    String state = request.getParameter("state");
    String redirectURI = URLEncoder.encode(REDIRECT_URI, "UTF-8");

    UrlBuilder ub = new UrlBuilder("https://nid.naver.com/oauth2.0/token");
    ub
      .add("grant_type", "authorization_code")
      .add("client_id", CLIENT_ID)
      .add("client_secret", CLI_SECRET)
      .add("redirect_uri", redirectURI)
      .add("code", code)
      .add("state", state);
    System.out.println(ub);

    String apiURL = ub.toString();

    String res = requestToServer(apiURL);

    if(res != null && !res.equals("")) {
      Map<String, Object> parsedJson = new JSONParser(res).parseObject();
      if(parsedJson.get("access_token") != null) {
        
        // 
        String infoStr = getProfileFromNaver(parsedJson.get("access_token").toString());
        Map<String, Object> infoMap = new JSONParser(infoStr).parseObject();
        if(infoMap.get("message").equals("success")) {
          Map<String, Object> infoResp = (Map<String, Object>) infoMap.get("response");
          String uniqueId = infoResp.get("id").toString();
          System.out.println(uniqueId);
          List<Map<String, String>> infoOAuth = sud.getOAuthInfoByProviderAndUniqueId("naver", uniqueId);
          if(infoOAuth.size() == 1) {
            System.out.println(infoOAuth);
            // 네아로 연동이 되어 있다면 연동 정보를 통해 로그인 처리
            // - 현재 로그인한 계정과 네아로 연결된 로그인 계정이 다른 경우, 현재 계정을 로그아웃하고 그 연결된 계정으로 재로그인
            loginWithoutForm(infoOAuth.get(0).get("username"));

            model.addAttribute("isConnectedToNaver", true);
          } else {
            System.out.println("네이버 연동 정보 없음");
            // 로그인이 되어 있다면 기존 아이디를 네아로와 연동할 것인지 확인 여부를 물음
            model.addAttribute("isConnectedToNaver", false);
          }
        }
        session.setAttribute("currentUser", res);
        session.setAttribute("currentAT", parsedJson.get("access_token"));
        session.setAttribute("currentRT", parsedJson.get("refresh_token"));
        

        model.addAttribute("res", res);
      } else {
        model.addAttribute("res", "Login failed!");
      }
      System.out.println(parsedJson);
    } else {
      model.addAttribute("res", "Login failed!");
    }
    return "test-naver-callback";
  }

  /**
   * 토큰 갱신 요청 페이지 컨트롤러
   * @param session
   * @param request
   * @param model
   * @param refreshToken
   * @return
   * @throws IOException
   * @throws ParseException
   */
    
   생략

  /**
   * 토큰 삭제 컨트롤러
   * @param session
   * @param request
   * @param model
   * @param accessToken
   * @return
   * @throws IOException
   */
  
   생략

  /**
   * 액세스 토큰으로 네이버에서 프로필 받기
   * @param accessToken
   * @return
   * @throws IOException
   */
  @ResponseBody
  @RequestMapping("/naver/getProfile")
  public String getProfileFromNaver(String accessToken) throws IOException {

    // 네이버 로그인 접근 토큰;
    String apiURL = "https://openapi.naver.com/v1/nid/me";
    String headerStr = "Bearer " + accessToken; // Bearer 다음에 공백 추가
    String res = requestToServer(apiURL, headerStr);
    return res;
  }

  /**
   * 세션 무효화(로그아웃)
   * @param session
   * @return
   */
  
   생략

  /**
   * 서버 통신 메소드
   * @param apiURL
   * @return
   * @throws IOException
   */
  private String requestToServer(String apiURL) throws IOException {
    return requestToServer(apiURL, "");
  }

  /**
   * 서버 통신 메소드
   * @param apiURL
   * @param headerStr
   * @return
   * @throws IOException
   */
  private String requestToServer(String apiURL, String headerStr) throws IOException {
    URL url = new URL(apiURL);
    HttpURLConnection con = (HttpURLConnection)url.openConnection();
    con.setRequestMethod("GET");

    System.out.println("header Str: " + headerStr);
    if(headerStr != null && !headerStr.equals("") ) {
      con.setRequestProperty("Authorization", headerStr);
    }

    int responseCode = con.getResponseCode();
    BufferedReader br;

    System.out.println("responseCode="+responseCode);

    if(responseCode == 200) { // 정상 호출
      br = new BufferedReader(new InputStreamReader(con.getInputStream()));
    } else {  // 에러 발생
      br = new BufferedReader(new InputStreamReader(con.getErrorStream()));
    }
    String inputLine;
    StringBuffer res = new StringBuffer();
    while ((inputLine = br.readLine()) != null) {
      res.append(inputLine);
    }
    br.close();
    if(responseCode==200) {
      return res.toString();
    } else {
      return null;
    }

  }

}

대부분의 네아로 관련 액션은 콜백 부분에서 처리될 것입니다. 네아로 주소 가져오는 작업을 getNaverOAuthURI 이라는 메소드로 별도로 분리했습니다.

[the_ad id=”1804”]

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.springboot.security.dao;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class SimpleUserDAO {
  @Autowired JdbcTemplate jt;

  (.......)
  

  public String getRolesByUsername(String username) {
    return jt.queryForObject("select role from simple_users where username=?", new Object[] {username}, (rs, rowNum) -> {
      return rs.getString(1);
    });
  }
  
  public List<Map<String, String>> getOAuthInfoByProviderAndUniqueId(String provider, String uniqueId) {
    return jt.query("select * from users_oauth where provider=? and unique_id=?", 
        new Object[] { provider, uniqueId }, (rs, rowNum) -> {
          
      Map<String, String> aRow = new HashMap<>();
      aRow.put("seq", rs.getString("seq"));
      aRow.put("username", rs.getString("username"));
      aRow.put("provider", rs.getString("provider"));
      aRow.put("uniqueId", rs.getString("unique_id"));
      aRow.put("regDate", rs.getString("reg_date"));
      aRow.put("lastDate", rs.getString("last_date"));
      return aRow;
      
    });
  }

}

일반적인 DAO 입니다.getRolesByUsername는 사용자의 ROLE을 불러옵니다. getOAuthInfoByProviderAndUniqueId 메소드는 프로바이더와 고유 아이디를 이용해서 해당 네이버 계정이 기존 회원 테이블과 연결되었는지 확인합니다.

 

https://gist.github.com/ayaysir/efad25b1b3cd43c80b3964d24fae2bef

 

3. 뷰 페이지(Thymeleaf) 작성

login-form.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<html xmlns:th="http://www.thymeleaf.org"
  xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity4">

<head>
    <title>Please Login</title>
</head>

<body>
    <div th:fragment="content">
        <form name="f" th:action="@{/login}" method="post">
            <fieldset>
                <legend>Please Login</legend>
                <div th:if="${param.error}" class="alert alert-error">
                    Invalid username and password.
                </div>
                <div th:if="${param.logout}" class="alert alert-success">
                    You have been logged out.
                </div>
                <label for="username">Username</label>
                <input type="text" id="username" name="username" />
                <label for="password">Password</label>
                <input type="password" id="password" name="password" />
                <div class="form-actions">
                    <button type="submit" class="btn">Log in</button>
                </div>
                <div>
                    <h3>네이버 로그인</h3>
                    <a th:href="${naverApiURL}"><img height="50" src="http://static.nid.naver.com/oauth/small_g_in.PNG" /></a>
                </div>
            </fieldset>
        </form>
    </div>
</body>

</html>

 

test-naver-callback.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
  xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity4">
<head>
<meta charset="UTF-8">
<title>Callback</title>
<style>
  pre{
    overflow: scroll;
  }
</style>
<!-- <meta http-equiv="refresh" content="5;url=/naver"> -->
</head>
<body>
  <h1>콜백 페이지</h1>
  <pre th:text="${res}"></pre>
  <p>5초 후에 메인 페이지로 돌아갑니다. 원래는 이 화면이 사용자에게 보이지 않고 바로 리다이렉트 되어야 합니다.</p>
  <a href="/naver">go to naver test page</a>
  <a href="/">go to main page</a>
  
  <th:block sec:authorize="isAuthenticated()">
    <div th:unless="${isConnectedToNaver}">
      <h5>현재 아이디를 네이버 아이디와 연동하시겠습니까?</h5>
      <a>[YES]</a> <a>[NO]</a>
    </div>
    <div th:if="${isConnectedToNaver}">
      <h5>현재 아이디 연동이 되어 있습니다.</h5>
    </div>
  </th:block>
</body>
</html>

 

4. 테스트

[caption id=”attachment_1953” align=”alignnone” width=”385”] 현재 로그인되지 않은 상태이며 로그인 버튼이 있습니다.[/caption]

 

[the_ad id=”1804”]

 

[caption id=”attachment_1949” align=”alignnone” width=”603”] 로그인 버튼을 누르면 밑에 네이버 로그인 버튼이 새로 추가된 것을 볼 수 있습니다.[/caption]

 

[caption id=”attachment_1950” align=”alignnone” width=”654”] 네이버 아이디로 로그인이 성공하면 콜백 페이지가 나타납니다.[/caption]

 

[caption id=”attachment_1951” align=”alignnone” width=”629”] 메인 페이지로 가면 이전과 다르게 로그인이 된 것을 볼 수 있습니다.[/caption]

 

[caption id=”attachment_1952” align=”alignnone” width=”578”] 회원 전용 글쓰기가 정상적으로 동작합니다.[/caption]

This post is licensed under CC BY 4.0 by the author.