이번에는 Spring Security Oauth을 사용하지 않고 password credential 방식을 직접 구현해보겠습니다.
DB는 user 테이블은 스키마는 그대로 사용하겠습니다.
삭제하고 다시 만들어도 상관없습니다.
그 외에 oauth_client, oauth_refresh_token 라는 테이블을 각각 생성하고
client, refresh token 데이터를 다루는 용도 사용하겠습니다.
각 테이블 생성 SQL은 Gihub 소스의 src/main/resources/db/scheme.sql 파일을 참고해주세요.
※ 개발 환경 : STS 4.8.1, JDK 1.8, Spring Boot 2.1.6, Mybatis 2.1, MySQL Community 5.6, Maven 4.0.0
1. Authorization Server 구현을 위한 Spring Boot 프로젝트 생성 및 의존성 설정
프로젝트, 패키지 명은 적절하게 선택하여 Spring Stater Project 생성합니다.
의존성 설정은 아래의 pom.xml 파일을 참고해주세요.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.6.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.codejcd</groupId>
<artifactId>SpringbootMybatisMysqlOauth2.0-AuthServer2</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>SpringbootMybatisMysqlOauth2.0-AuthServer2</name>
<description>SpringbootMybatisMysqlOauth2.0-AuthServer2</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.springframework.security/spring-security-crypto -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
<version>5.1.5.RELEASE</version><!--$NO-MVN-MAN-VER$-->
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- spring-security-crypto
PasswordEncoder 구현에 사용됩니다. - jjwt
JWT 구현에 사용됩니다.
2. Authorization Server 프로젝트 구조
아래 이미지와 같은 구조를 가지게 됩니다.
- com.codjecd.common : Custom Exception 등 Custom 패키지, MessageProperties 등 공통 컴포넌트 패키지
- com.codejcd.config : 각종 설정 파일들의 패키지
- com.codejcd.dao : DB 액세스를 파일들을 위한 패키지
- com.codejcd.entity : 엔티티를 파일들을 위한 패키지
- com.codejcd.service : 서비스 레이어 패키지
- com.codejcd.controller : 컨트롤러 레이어 패키지
- com.codejcd.util : 유틸 파일 패키지
3. Mybatis 설정
MybatisConfiguration 클래스를 생성하고 Properties에서 읽어올 설정 값과
SQL Session과 Mapper 설정을 합니다.
package com.codejcd.config;
import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import com.zaxxer.hikari.HikariDataSource;
@Configuration
@MapperScan(basePackages = "com.codejcd.*.dao.**", sqlSessionFactoryRef = "sqlSessionFactory")
public class MybatisConfiguration {
@Bean(name = "dataSource")
@ConfigurationProperties(prefix = "spring.datasource.hikari")
public DataSource dataSource() {
return new HikariDataSource();
}
@Bean(name = "sqlSessionFactory")
public SqlSessionFactory sqlSessionFactory (
@Qualifier("dataSource") DataSource dataSource,
ApplicationContext applicationContext) throws Exception {
SqlSessionFactoryBean sqlSessionFactory = new SqlSessionFactoryBean();
sqlSessionFactory.setDataSource(dataSource);
sqlSessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("mybatis/mapper/*.xml"));
return sqlSessionFactory.getObject();
}
@Bean(name = "sqlSession")
public SqlSessionTemplate sqlSession(SqlSessionFactory sqlSessionFactory) throws Exception {
return new SqlSessionTemplate(sqlSessionFactory);
}
}
4. Message Source 설정
Exception 등 서버에서 처리할 메시지를 정의하고 맵핑하는 설정.
/src/main/resources/messages/ message.*.properties 파일들과 맵핑됩니다.
Locale을 KOREA로 설정했기떄문에 message.ko.properties 가 기본 메시지 프로퍼티로 사용됩니다.
4.1 Message Source Configuration
package com.codejcd.config;
import java.util.Locale;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
@Configuration
public class MessageSourceConfiguration {
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource source = new ReloadableResourceBundleMessageSource();
source.setBasename("classpath:/messages/message"); // 메시지 파일 위치
source.setDefaultEncoding("UTF-8");
source.setCacheSeconds(10);
source.setUseCodeAsDefaultMessage(true);
Locale.setDefault(Locale.KOREA); // 기본 Locale
return source;
}
}
4.2 Message Source Properties
src/main/resources/messages 폴더를 생성하고
message_ko.properties 파일을 생성합니다.
code와 message 값을 정의합니다.
프로젝트의 크기에 따라 code 의 범위는 적절하게 조절하세요.
여기서는 데모 앱이므로 범위가 작습니다.
error.code.common.success=000
error.message.common.success=Process Success
error.code.common.occured.exception=999
error.message.common.occured.exception=Process Fail
error.code.userId.invalid=000
error.message.userId.invalid=User ID is invalid
error.code.userPassword.invalid=001
error.message.userPassword.invalid=User Password is invalid
error.code.token.invalid=010
error.message.token.invalid=token is invalid
error.code.token.expiration.invalid=011
error.message.token.expiration.invalid=token expiration is invalid
error.code.token.expiration=012
error.message.token.expiration=token is expired
error.code.token.issuer.invalid=013
error.message.token.issuer.invalid=issuer is invalid
error.code.token.audience.invalid=014
error.message.token.audience.invalid=audience is invalid
error.code.token.id.invalid=015
error.message.token.id.invalid=token ID is invalid
error.code.authorization.invalid=020
error.message.authorization.invalid=authorization is invalid
error.code.client.secretkey.invalid=030
error.message.client.secretkey.invalid=client secret key is invalid
5. application.properties
MySQL DB에 접근하기 위한 설정을 추가합니다.
테스트 설정이니 참고만 해주시고 절대로 상용에서 사용하면 안 되는 비밀번호입니다.
server.port=8095
spring.datasource.hikari.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.hikari.jdbc-url=jdbc:mysql://localhost:3306/oauth2?characterEncoding=UTF-8&serverTimezone=Asia/Seoul
spring.datasource.hikari.username=root
spring.datasource.hikari.password=1234
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.connection-test-query=SELECT 1 FROM DUAL
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=100000
6. jwt.properties
JWT 설정 값들을 관리하는 파일입니다.
JWT Access Token 과 Refresh Token 생성에 필요한 값들.
7. Custom Exception
Custom Exception을 처리하기 위한 클래스입니다.
Exception 클래스를 상속 받고 error code, message, description을 처리하기 위한 멤버 변수를 가집니다.
package com.codejcd.common;
public class CustomException extends Exception {
private static final long serialVersionUID = -3642478745758696786L;
private String errorCode;
private String errorMessage;
private String errorDescription;
public CustomException() {
}
public CustomException(String errorCode, String errorMessage) {
this.errorCode = errorCode;
this.errorMessage = errorMessage;
}
public CustomException(String errorCode, String errorMessage, String errorDescription) {
this.errorCode = errorCode;
this.errorMessage = errorMessage;
this.errorDescription = errorDescription;
}
public String getErrorCode() {
return errorCode;
}
public void setErrorCode(String errorCode) {
this.errorCode = errorCode;
}
public String getErrorMessage() {
return errorMessage;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
public String getErrorDescription() {
return errorDescription;
}
public void setErrorDescription(String errorDescription) {
this.errorDescription = errorDescription;
}
}
8. Custom Password Encoder
Bcrypt 인코딩 방식을 사용하는 Password Encoder 구현.
Commponet 어노테이션을 사용하여 빈으로 등록한다.
package com.codejcd.common;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
/**
* BCrypt Passwodencoder Component
* BCrypt 의존성이 주입된 passwordencoder
*/
@Component
public class CustomPasswordEncoder implements PasswordEncoder {
private PasswordEncoder passwordEncoder;
public CustomPasswordEncoder() {
this.passwordEncoder = new BCryptPasswordEncoder();
}
@Override
public String encode(CharSequence rawPassword) {
return passwordEncoder.encode(rawPassword);
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return passwordEncoder.matches(rawPassword, encodedPassword);
}
}
9. Custom Response
응답을 위한 Response 객체를 구현.
응답 코드(responseCode), 응답 메시지(responseMessage), 응답 결과 객체(result)를 멤버변수로 가진다.
package com.codejcd.common;
/**
* 커스터마이징 응답 객체
*/
public class Response {
private String responseCode;
private String responseMessage;
private Object result;
public String getResponseCode() {
return responseCode;
}
public void setResponseCode(String responseCode) {
this.responseCode = responseCode;
}
public String getResponseMessage() {
return responseMessage;
}
public void setResponseMessage(String responseMessage) {
this.responseMessage = responseMessage;
}
public Object getResult() {
return result;
}
public void setResult(Object result) {
this.result = result;
}
}
10. Date Utill
날짜, 시간 계산을 편하게 하기 위한 유틸 클래스.
package com.codejcd.util;
import java.text.ParsePosition;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
/**
* Date Util
*
*/
public class DateUtil {
public static final String YYYYMMDDHHmm = "yyyyMMddHHmm";
public static Date getDate(String dateString, String format) throws Exception{
SimpleDateFormat formatter = new SimpleDateFormat(format);
ParsePosition pos = new ParsePosition(0);
return formatter.parse(dateString, pos);
}
public static String getNow(String fromFormat) {
Calendar cal = Calendar.getInstance();
Date today = cal.getTime();
SimpleDateFormat formatter = new SimpleDateFormat(fromFormat);
return formatter.format(today);
}
public static Date getNowDate() {
Calendar cal = Calendar.getInstance();
return cal.getTime();
}
public static String addMinute(String dateString, String format, int minute){
Calendar cal = Calendar.getInstance();
SimpleDateFormat formatter = new SimpleDateFormat(format);
ParsePosition pos = new ParsePosition(0);
cal.setTime(formatter.parse(dateString, pos));
cal.add(Calendar.MINUTE,minute);
return formatter.format(cal.getTime());
}
public static String addHour(String dateString, String format, int hours){
Calendar cal = Calendar.getInstance();
SimpleDateFormat formatter = new SimpleDateFormat(format);
ParsePosition pos = new ParsePosition(0);
cal.setTime(formatter.parse(dateString, pos));
cal.add(Calendar.HOUR,hours);
return formatter.format(cal.getTime());
}
public static String addDate(String dateString, String format, int days){
Calendar cal = Calendar.getInstance();
SimpleDateFormat formatter = new SimpleDateFormat(format);
ParsePosition pos = new ParsePosition(0);
cal.setTime(formatter.parse(dateString, pos));
cal.add(Calendar.DATE,days);
return formatter.format(cal.getTime());
}
public static boolean isBefore(Date from, Date to) {
if (from == null || to == null) {
return false;
}
if (to.before(from)) {
return false;
}
return true;
}
}
- getDate 메서드
String 날짜 값, 포맷 값을 매개변수로 받아서
날짜를 Date로 반환. - getNow 메서드
String 포맷 값을 매개변수로 받아서
오늘 날짜를 String로 반환. - getNowDate 메서드
매개변수 없이 오늘 날짜를 Date로 반환. - addMinute 메서드
String 날짜 값, 포맷, 추가할 시간(Minute)을 매개변수로 받아서
연산하고 날짜 값을 String으로 반환. - addHour 메서드
String 날짜 값, 포맷, 추가할 시간(Hour)을 매개변수로 받아서
연산하고 날짜 값을 String으로 반환. - addDate 메서드
String 날짜 값, 포맷, 추가할 시간(days)을 매개변수로 받아서
연산하고 날짜 값을 String으로 반환. - isBefore 메서드
Date 비교할 두 날짜 값을 매개변수로 받아서
비교하고 이전 날짜 여부를 판단한 값을 boolean 으로 반환.
11. Client, Token, RefreshToken, User Entity 구현
Client, Token, RefreshToken, User 객체를 생성합니다.
RefreshToken은 Token을 상속받습니다.
멤버변수와 getter/setter에 대한 내용이므로 소스를 확인해주시고
설명은 생략하겠습니다.
package com.codejcd.entity;
public class Client {
private int clientSeq;
private String clientId;
private String clientSecret;
private String status;
private String regDate;
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public String getClientSecret() {
return clientSecret;
}
public void setClientSecret(String clientSecret) {
this.clientSecret = clientSecret;
}
public int getClientSeq() {
return clientSeq;
}
public void setClientSeq(int clientSeq) {
this.clientSeq = clientSeq;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getRegDate() {
return regDate;
}
public void setRegDate(String regDate) {
this.regDate = regDate;
}
}
package com.codejcd.entity;
public class Token {
private String token;
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
}
package com.codejcd.entity;
public class RefreshToken extends Token {
private int tokenId;
private int refreshTokenSeq;
private String authentication;
public int getRefreshTokenSeq() {
return refreshTokenSeq;
}
public void setRefreshTokenSeq(int refreshTokenSeq) {
this.refreshTokenSeq = refreshTokenSeq;
}
public String getAuthentication() {
return authentication;
}
public void setAuthentication(String authentication) {
this.authentication = authentication;
}
public int getTokenId() {
return tokenId;
}
public void setTokenId(int tokenId) {
this.tokenId = tokenId;
}
}
package com.codejcd.entity;
public class User {
private int userSeq;
private String userId;
private String password;
private String name;
private String status;
public int getUserSeq() {
return userSeq;
}
public void setUserSeq(int userSeq) {
this.userSeq = userSeq;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}
12. Client, Refresh Token , User Mapper 구현
12.1 Client Mapper
oauth_client 테이블에 접근하여 SQL 을 사용하여 조회하는 Mapper입니다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.codejcd.mapper.ClientMapper">
<sql id="table_name">
oauth_client
</sql>
<select id="selectClientByClientId" parameterType="com.codejcd.entity.Client" resultType="com.codejcd.entity.Client" >
SELECT client_seq as clientSeq
, client_id as clientId
, client_secret as clientSecret
, status as status
, reg_date as regDate
FROM <include refid="table_name" />
WHERE client_id = #{clientId}
</select>
<insert id="registClient" parameterType="com.codejcd.entity.Client" keyProperty="clientSeq" useGeneratedKeys="true" >
INSERT INTO <include refid="table_name" />
(
client_seq
, client_id
, client_secret
, status
, reg_date
) VALUES
(
#{clientSeq}
, #{clientId}
, #{clientSecret}
, 'A'
, now()
)
</insert>
</mapper>
- selectClientByClientId
oauth_client 테이블을 client_id 칼럼을 WHERE 조건문으로 조회하는 SQL입니다. - registClient
oauth_client 테이블에 cleint 정보를 등록하는 SQL입니다.
12.2 Refresh Token Mapper
oauth_refresh_token 테이블에 접근하여 SQL을 사용하여 조회하는 Mapper입니다.
이전 글에서 구현 시에는 refresh_token을 저장하지는 않았는데요.
보통 Access Token은 만료 주기를 짧게 설정하고 Refresh Token 만료 주기를 길게 설정합니다.
이럴 경우 Refresh Token이 탈취당할 경우 계속 Access Token을 발급받을 수 있기 때문에
Refresh Token을 비교하여 수정/삭제 같은 조치를 하여 접근 제어를 하는 방안이 있습니다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.codejcd.mapper.RefreshTokenMapper">
<select id="countRefreshTokenByToken" parameterType="com.codejcd.entity.RefreshToken" resultType="int" >
SELECT COUNT(token_id)
FROM oauth_refresh_token
WHERE token = #{token}
</select>
<select id="selectRefreshTokenByAuthentication" parameterType="com.codejcd.entity.RefreshToken" resultType="com.codejcd.entity.RefreshToken">
SELECT token_id AS tokenId
, token AS token
, authentication AS authentication
FROM oauth_refresh_token
WHERE authentication = #{authentication}
</select>
<insert id="insertRefreshToken" parameterType="com.codejcd.entity.RefreshToken" keyProperty="tokenId" useGeneratedKeys="true" >
INSERT INTO oauth_refresh_token
(
token_id
, token
, authentication
) VALUES
(
#{tokenId}
, #{token}
, #{authentication}
)
</insert>
<delete id="deleteRefreshTokenByAuthentication" parameterType="com.codejcd.entity.RefreshToken">
DELETE
FROM oauth_refresh_token
WHERE authentication = #{authentication}
</delete>
<update id="updateRefreshTokenByAuthentication" parameterType="com.codejcd.entity.RefreshToken" >
UPDATE oauth_refresh_token SET
token = #{token}
WHERE authentication = #{authentication}
</update>
</mapper>
- countRefreshTokenByToken
oauth_refresh_token 테이블에 token 칼럼을
WHERE 조건문으로 조회하여 결과 값을 카운트하는 SQL입니다. - selectRefreshTokenByAuthentication
oauth_refresh_token 테이블에 token 칼럼을
WHERE 조건문으로 조회하여 결과 값을 얻는 SQL입니다. - insertRefreshToken
oauth_refresh_token 테이블에 Refresh Token 정보를 등록하는 SQL입니다. - deleteRefreshTokenByAuthentication
oauth_refresh_token 테이블에 authentication 칼럼을
WHERE 조건문으로 삭제하는 SQL입니다. - updateRefreshTokenByAuthentication
oauth_refresh_token 테이블에 authentication 칼럼을
WHERE 조건문으로 하여, token 칼럼 값을 업데이트하는 SQL입니다.
12.3 User Mapper
user 테이블에 접근하여 SQL을 사용하여 조회하는 Mapper입니다.
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.codejcd.mapper.UserMapper">
<select id="selectUserByUserId" parameterType="String" resultType="com.codejcd.entity.User" >
SELECT user_seq as userSeq
, user_id as userId
, password as password
, name as name
, status as status
, reg_date as regDate
FROM user
WHERE user_id = #{userId}
</select>
<select id="selectUser" parameterType="com.codejcd.entity.User" resultType="com.codejcd.entity.User">
SELECT user_seq as userSeq
, user_id as userId
, password as password
, name as name
, status as status
, reg_date as regDate
FROM user
WHERE user_id = #{userId}
AND password = #{password}
</select>
<insert id="registUser" parameterType="com.codejcd.entity.User" keyProperty="userSeq" useGeneratedKeys="true" >
INSERT INTO user
(
user_seq
, user_id
, name
, password
, status
, reg_date
) VALUES
(
#{userSeq}
, #{userId}
, #{name}
, #{password}
, 'A'
, now()
)
</insert>
</mapper>
- selectUserByUserId
user 테이블을 user_id 칼럼을 WHERE 조건문으로 조회하여
결과 값을 얻는 SQL입니다. - selectUser
user 테이블을 user_id 칼럼과 password 칼럼을
WHERE 조건문으로 조회하여 결과 값을 얻는 SQL입니다. - registUser
user 테이블에 user 정보를 등록하는 SQL입니다.
13. Client, Refresh Token , User DAO 구현
Client, Refresh Token, User SQL Mapper에 접근하여 결과 값을 얻을 DAO를 작성합니다.
Repository 어노테이션을 통해 Bean으로 등록합니다.
package com.codejcd.dao;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Repository;
import com.codejcd.entity.Client;
@Repository
public class ClientDao {
private final static String NAMESPACE = "com.codejcd.mapper.ClientMapper.";
@Autowired
@Qualifier("sqlSession")
private SqlSessionTemplate sqlSession;
public Client selectClientByClientId(String clientId) {
Client client = new Client();
client.setClientId(clientId);
return sqlSession.selectOne(NAMESPACE + "selectClientByClientId", client);
}
public int registClient(String clientId, String clientSecret) {
Client client = new Client();
client.setClientId(clientId);
client.setClientSecret(clientSecret);
return sqlSession.insert(NAMESPACE + "registClient", client);
}
}
package com.codejcd.dao;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Repository;
import com.codejcd.entity.RefreshToken;
@Repository
public class RefreshTokenDao {
private final static String NAMESPACE = "com.codejcd.mapper.RefreshTokenMapper.";
@Autowired
@Qualifier("sqlSession")
private SqlSessionTemplate sqlSession;
public int countRefreshToken(RefreshToken refreshToken) {
return sqlSession.selectOne(NAMESPACE+ "countRefreshTokenByToken", refreshToken);
}
public RefreshToken selectRefreshToken(RefreshToken refreshToken) {
return sqlSession.selectOne(NAMESPACE+ "selectRefreshTokenByAuthentication", refreshToken);
}
public int insertRefreshToken(RefreshToken refreshToken) {
return sqlSession.insert(NAMESPACE + "insertRefreshToken", refreshToken);
}
public int deleteRefreshToken(RefreshToken refreshToken) {
return sqlSession.delete(NAMESPACE+ "deleteRefreshTokenByAuthentication", refreshToken);
}
public int updateRefreshToken(RefreshToken refreshToken) {
return sqlSession.delete(NAMESPACE+ "updateRefreshTokenByAuthentication", refreshToken);
}
}
package com.codejcd.dao;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Repository;
import com.codejcd.entity.User;
@Repository
public class UserDao {
private final static String NAMESPACE = "com.codejcd.mapper.UserMapper.";
@Autowired
@Qualifier("sqlSession")
private SqlSessionTemplate sqlSession;
public User selectUserByUserId(String userId) {
return sqlSession.selectOne(NAMESPACE + "selectUserByUserId", userId);
}
public User selectUser(String userId, String password) {
User user = new User();
user.setUserId(userId);
user.setPassword(password);
return sqlSession.selectOne(NAMESPACE + "selectUser", user);
}
public int registUser(String userId, String userName, String password) {
User user = new User();
user.setUserId(userId);
user.setName(userName);
user.setPassword(password);
return sqlSession.insert(NAMESPACE + "registUser", user);
}
}
14. Client Service, Token Service, User Service Impl
Serivce 인터페이스와 구현체 클래스로 구성됩니다.
Service 어노테이션으로 Bean 등록을 합니다.
현재는 인터페이스 구현체 관계가 1:1이기 때문에 Service Interface 경우 생략 가능합니다.
OCP 관점보다는 구현을 위한 최초 설계도 역할을 했다고 보면 될 것 같습니다.
1:1 관계에서 인터페이스 구현체 관계는 관습적인 불필요한 관계로 보고 있으니 프로젝트마다
코딩 컨벤션을 잘 정해서 따르면 될 것 같습니다.
14.1 Cllint Service, impl
package com.codejcd.service;
public interface ClientService {
/**
* 클라이언트 조회 및 검증
* @param authorization
* @return
* @throws Exception
*/
public void checkClientByClientId(String authorization) throws Exception;
/**
* 클라이언트 등록
* @param clientId
* @param clientSecret
* @return
* @throws Exception
*/
public int registClient(String clientId, String clientSecret) throws Exception;
}
package com.codejcd.service;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.codejcd.common.CustomException;
import com.codejcd.common.CustomPasswordEncoder;
import com.codejcd.common.MessageProperties;
import com.codejcd.dao.ClientDao;
import com.codejcd.entity.Client;
@Service
public class ClientServiceImpl implements ClientService {
@Autowired
private ClientDao clientDao;
@Autowired
private CustomPasswordEncoder passwordEncoder;
@Override
public void checkClientByClientId(String authorization) throws Exception {
String[] client = null;
if (authorization == null || !authorization.toLowerCase().startsWith("basic")) {
throw new CustomException(MessageProperties.prop("error.code.authorization.invalid")
, MessageProperties.prop("error.message.authorization.invalid"));
}
// Authorization: Basic base64credentials
String base64Credentials = authorization.substring("Basic".length()).trim();
byte[] credDecoded = Base64.getDecoder().decode(base64Credentials);
String credentials = new String(credDecoded, StandardCharsets.UTF_8);
// credentials = username:password
client = credentials.split(":", 2);
Client resultClient = clientDao.selectClientByClientId(client[0]);
if (null == resultClient) {
throw new CustomException(MessageProperties.prop("error.code.authorization.invalid")
, MessageProperties.prop("error.message.authorization.invalid"));
}
boolean result = passwordEncoder.matches(client[1], resultClient.getClientSecret());
if (!result) {
throw new CustomException(MessageProperties.prop("error.code.client.secretkey.invalid")
, MessageProperties.prop("error.message.client.secretkey.invalid"));
}
}
@Override
public int registClient(String clientId, String clientSecret) throws Exception {
clientSecret = passwordEncoder.encode(clientSecret);
return clientDao.registClient(clientId, clientSecret);
}
}
- selectClientByClientId 메서드
클라이언트 정보를 조회하여 유효한지 검증하고
유효하지 않은 경우 Exception을 throw 합니다.
클라이언트 정보는 컨트롤러를 통해 전달되는데 - API 호출 시 HTTP Header 값에 authorization Basic 세팅하는 값이고
Base64 Encoding 되어 있기 때문에 이를 Decoding 하여
저장된 클라이언트 값과 비교하여 유효성을 검증합니다. - registClient 메서드
클라이언트 정보를 매개변수로 받아서
클라이언트 정보를 저장하는 DAO를 호출합니다.
client secrert 값은 앞에서 미리 생성한
password encoder를 사용하여 encoding 합니다.
14.2 Token Service, Impl
package com.codejcd.service;
import java.util.HashMap;
import com.codejcd.common.CustomException;
import com.codejcd.entity.User;
public interface TokenService {
/**
* 액세스 및 리프레시 토큰 발행
* @param user
* @return
* @throws Exception
*/
public HashMap<String, Object> getTokens(User user) throws Exception;
/**
* 액세스 토큰 생성
* @param user
* @return
* @throws Exception
*/
public String getAccessToken(User user) throws Exception;
/**
* 액세스 토큰 검사
* @param accessToken
* @return
* @throws Exception
*/
public boolean checkAccessToken(String accessToken) throws Exception;
/**
* 액세스 토큰 재발행
* @param accessToken
* @return
* @throws Exception
*/
public String refreshAccessToken(String accessToken) throws Exception;
/**
* 리프레시 토큰 생성
* @param user
* @return
* @throws Exception
*/
public String getRefreshToken(User user) throws Exception;
/**
* 리프레시 토큰 검사
* @param accessToken
* @return
* @throws Exception
*/
public User checkRefreshToken(String refreshToken) throws CustomException, Exception;
}
package com.codejcd.service;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import org.apache.tomcat.util.codec.binary.Base64;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.PropertySource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.codejcd.common.CustomException;
import com.codejcd.common.MessageProperties;
import com.codejcd.dao.RefreshTokenDao;
import com.codejcd.entity.RefreshToken;
import com.codejcd.entity.User;
import com.codejcd.util.DateUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
@Service
@PropertySource("classpath:jwt.properties")
public class TokenServiceImpl implements TokenService {
@Value("${jwt.accessToken.secretKey}")
private String ACCESS_TOKEN_SECRET_KEY;
@Value("${jwt.accessToken.expireHour}")
private String ACCESS_TOKEN_EXPIRE_DAY;
@Value("${jwt.accessToken.issuer}")
private String ACCESS_TOKEN_ISSUER;
@Value("${jwt.accessToken.audience}")
private String ACCESS_TOKEN_AUDIENCE;
@Value("${jwt.accessToken.subject}")
private String ACCESS_TOKEN_SUBJECT;
@Value("${jwt.refreshToken.secretKey}")
private String REFRESH_TOKEN_SECRET_KEY;
@Value("${jwt.refreshToken.expireDay}")
private String REFRESH_TOKEN_EXPIRE_DAY;
@Value("${jwt.refreshToken.issuer}")
private String REFRESH_TOKEN_ISSUER;
@Value("${jwt.refreshToken.audience}")
private String REFRESH_TOKEN_AUDIENCE;
@Value("${jwt.refreshToken.subject}")
private String REFRESH_TOKEN_SUBJECT;
@Autowired
private UserService userService;
@Autowired
private RefreshTokenDao refreshTokenDao;
@Override
@Transactional(rollbackFor = {Exception.class})
public HashMap<String, Object> getTokens(User user) throws Exception {
String accessToken = getAccessToken(user);
String refreshToken = getRefreshToken(user);
HashMap<String, Object> resultMap = new HashMap<String, Object>();
resultMap.put("accessToken", accessToken);
resultMap.put("refreshToken", refreshToken);
return resultMap;
}
@Override
public String getAccessToken(User user) throws Exception {
if (null == user) {
throw new CustomException(MessageProperties.prop("error.code.userId.invalid")
, MessageProperties.prop("error.message.userId.invalid"));
}
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(Base64.encodeBase64String(ACCESS_TOKEN_SECRET_KEY.getBytes()));
Key signKey = new SecretKeySpec(secretKeyBytes, signatureAlgorithm.getJcaName());
//String strExpiration = DateUtil.addHour(DateUtil.getNow(DateUtil.YYYYMMDDHHmm), DateUtil.YYYYMMDDHHmm, Integer.parseInt(ACCESS_TOKEN_EXPIRE_DAY));
String strExpiration = DateUtil.addMinute(DateUtil.getNow(DateUtil.YYYYMMDDHHmm), DateUtil.YYYYMMDDHHmm, 1);
Date expiration = DateUtil.getDate(strExpiration, DateUtil.YYYYMMDDHHmm);
//System.out.println("strExp : " + expiration);
String userId = user.getUserId();
if (null == userId && "".equals(userId)) {
throw new CustomException(MessageProperties.prop("error.code.userId.invalid")
, MessageProperties.prop("error.message.userId.invalid"));
}
JwtBuilder jwtBuilder = Jwts.builder()
.setId(userId) //jti(고유식별자)
.setAudience(ACCESS_TOKEN_AUDIENCE) // 토큰 대상자
.setIssuedAt(DateUtil.getNowDate()) //iat 발행날짜
.setSubject(ACCESS_TOKEN_SUBJECT) //sub 제목
.setIssuer(ACCESS_TOKEN_ISSUER) // iss 발급자
.setExpiration(expiration) // exp 만료날짜
.signWith(signatureAlgorithm, signKey); // sign algo, key
String temp =jwtBuilder.compact();
checkAccessToken(temp);
return temp;
}
@Override
public boolean checkAccessToken(String accessToken) throws Exception {
if (null == accessToken || "".equals(accessToken)) {
throw new CustomException(MessageProperties.prop("error.code.token.invalid")
, MessageProperties.prop("error.message.token.invalid"));
}
boolean result = false;
byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(Base64.encodeBase64String(ACCESS_TOKEN_SECRET_KEY.getBytes()));
Claims claims = null;
claims = Jwts.parser()
.setSigningKey(secretKeyBytes)
.parseClaimsJws(accessToken).getBody();
Date expitration;
Date now = DateUtil.getNowDate();
String id;
String issuer;
String audience;
if (null == claims.getExpiration()) {
throw new CustomException(MessageProperties.prop("error.code.token.expiration.invalid")
, MessageProperties.prop("error.message.token.expiration.invalid"));
}
expitration = claims.getExpiration();
System.out.println(expitration);
if (DateUtil.isBefore(expitration, now)) {
throw new CustomException(MessageProperties.prop("error.code.token.expiration")
, MessageProperties.prop("error.message.token.expiration"));
}
if (null == claims.getIssuer() || "".equals(claims.getIssuer())) {
throw new CustomException(MessageProperties.prop("error.code.token.issuer.invalid")
, MessageProperties.prop("error.message.token.issuer.invalid"));
}
issuer = claims.getIssuer();
if (!ACCESS_TOKEN_ISSUER.equals(issuer)) {
throw new CustomException(MessageProperties.prop("error.code.token.issuer.invalid")
, MessageProperties.prop("error.message.token.issuer.invalid"));
}
if (null == claims.getAudience() || "".equals(claims.getAudience())) {
throw new CustomException(MessageProperties.prop("error.code.token.audience.invalid")
, MessageProperties.prop("error.message.token.audience.invalid"));
}
audience = claims.getAudience();
if (!ACCESS_TOKEN_AUDIENCE.equals(audience)) {
throw new CustomException(MessageProperties.prop("error.code.token.audience.invalid")
, MessageProperties.prop("error.message.token.audience.invalid"));
}
if (null == claims.getId() || "".equals(claims.getId())) {
throw new CustomException(MessageProperties.prop("error.code.token.id.invalid")
, MessageProperties.prop("error.message.token.id.invalid"));
}
id = claims.getId();
User user = userService.selectUserByUserId(id);
if (null == user) {
throw new CustomException(MessageProperties.prop("error.code.token.id.invalid")
, MessageProperties.prop("error.message.token.id.invalid"));
}
result = true;
//claims.getIssuedAt();
//claims.getSubject();
return result;
}
@Override
public String refreshAccessToken(String refreshToken) throws Exception {
User user = checkRefreshToken(refreshToken);
return getAccessToken(user);
}
@Override
public String getRefreshToken(User user) throws Exception {
if (null == user) {
throw new CustomException(MessageProperties.prop("error.code.userId.invalid")
, MessageProperties.prop("error.message.userId.invalid"));
}
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(Base64.encodeBase64String(REFRESH_TOKEN_SECRET_KEY.getBytes()));
Key signKey = new SecretKeySpec(secretKeyBytes, signatureAlgorithm.getJcaName());
String strExpiration = DateUtil.addDate(DateUtil.getNow(DateUtil.YYYYMMDDHHmm), DateUtil.YYYYMMDDHHmm, Integer.parseInt(REFRESH_TOKEN_EXPIRE_DAY));
Date expiration = DateUtil.getDate(strExpiration, DateUtil.YYYYMMDDHHmm);
String userId = user.getUserId();
if (null == userId && "".equals(userId)) {
throw new CustomException(MessageProperties.prop("error.code.userId.invalid")
, MessageProperties.prop("error.message.userId.invalid"));
}
JwtBuilder jwtBuilder = Jwts.builder()
.setId(userId) //jti(고유식별자)
.setAudience(REFRESH_TOKEN_AUDIENCE) // 토큰 대상자
.setIssuedAt(DateUtil.getNowDate()) //iat 발행날짜
.setSubject(REFRESH_TOKEN_SUBJECT) //sub 제목
.setIssuer(REFRESH_TOKEN_ISSUER) // iss 발급자
.setExpiration(expiration) // exp 만료날짜
.signWith(signatureAlgorithm, signKey); // sign algo, key
final String token = jwtBuilder.compact();
RefreshToken refreshToken = new RefreshToken();
refreshToken.setAuthentication(String.valueOf(user.getUserSeq()));
refreshToken.setToken(token);
RefreshToken duplicationRefreshToken = refreshTokenDao.selectRefreshToken(refreshToken);
int result = 0;
if (null != duplicationRefreshToken) {
refreshToken.setRefreshTokenSeq(duplicationRefreshToken.getRefreshTokenSeq());
result = refreshTokenDao.updateRefreshToken(refreshToken);
} else {
result = refreshTokenDao.insertRefreshToken(refreshToken);
}
if (0 == result) {
throw new CustomException(MessageProperties.prop("error.code.common.occured.exception")
, MessageProperties.prop("error.message.common.occured.exception"));
}
return token;
}
@Override
public User checkRefreshToken(String refreshToken) throws Exception {
if (null == refreshToken || "".equals(refreshToken)) {
throw new CustomException(MessageProperties.prop("error.code.token.invalid")
, MessageProperties.prop("error.message.token.invalid"));
}
byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(Base64.encodeBase64String(REFRESH_TOKEN_SECRET_KEY.getBytes()));
Claims claims = null;
claims = Jwts.parser()
.setSigningKey(secretKeyBytes)
.parseClaimsJws(refreshToken).getBody();
Date expitration;
Date now = DateUtil.getNowDate();
String id;
String issuer;
String audience;
if (null == claims.getExpiration()) {
throw new CustomException(MessageProperties.prop("error.code.token.expiration.invalid")
, MessageProperties.prop("error.message.token.expiration.invalid"));
}
expitration = claims.getExpiration();
if (DateUtil.isBefore(expitration, now)) {
throw new CustomException(MessageProperties.prop("error.code.token.expiration")
, MessageProperties.prop("error.message.token.expiration"));
}
if (null == claims.getIssuer() || "".equals(claims.getIssuer())) {
throw new CustomException(MessageProperties.prop("error.code.token.issuer.invalid")
, MessageProperties.prop("error.message.token.issuer.invalid"));
}
issuer = claims.getIssuer();
if (!REFRESH_TOKEN_ISSUER.equals(issuer)) {
throw new CustomException(MessageProperties.prop("error.code.token.issuer.invalid")
, MessageProperties.prop("error.message.token.issuer.invalid"));
}
if (null == claims.getAudience() || "".equals(claims.getAudience())) {
throw new CustomException(MessageProperties.prop("error.code.token.audience.invalid")
, MessageProperties.prop("error.message.token.audience.invalid"));
}
audience = claims.getAudience();
if (!REFRESH_TOKEN_AUDIENCE.equals(audience)) {
throw new CustomException(MessageProperties.prop("error.code.token.audience.invalid")
, MessageProperties.prop("error.message.token.audience.invalid"));
}
if (null == claims.getId() || "".equals(claims.getId())) {
throw new CustomException(MessageProperties.prop("error.code.token.id.invalid")
, MessageProperties.prop("error.message.token.id.invalid"));
}
id = claims.getId();
User user = userService.selectUserByUserId(id);
if (null == user) {
throw new CustomException(MessageProperties.prop("error.code.token.id.invalid")
, MessageProperties.prop("error.message.token.id.invalid"));
}
return user;
}
}
Value 어노테이션을 사용하여 이전 과정에서 jwt.properties에 설정한 값들을 가져와서 멤버 변수에 주입합니다.
Access Token 은 6시간, Refresh Token은 30일에 만료 주기를 갖습니다.
SignatureAlgorithm은 HS256을 사용합니다.
- getTokens 메서드
액세스 및 리프레시 토큰 발행하는 메서드입니다. - getAccessToken 메서드
JWT 액세스 토큰 생성하는 메서드입니다. - checkAccessToken 메서드
JWT 액세스 토큰의 유효성 검사를 하는 메서드입니다. - refreshAccessToken 메서드
JWT 액세스 토큰 재발행하는 메서드입니다.
DAO를 호출하여 리프레시 토큰이 저장된 테이블을 조회하고
중복이 있는 경우 토큰 정보만 업데이트하고 아닌 경우 정보를 저장합니다. - getRefreshToken 메서드
JWT 리프레시 토근 발행하는 메서드입니다. - checkRefreshToken 메서드
JWT 리프레시 토큰의 유효성 검사를 하는 메서드입니다.
14.3 User Service, Impl
package com.codejcd.service;
import com.codejcd.entity.User;
public interface UserService {
/**
* 유저 조회
* @param userId
* @return
* @throws Exception
*/
public User selectUserByUserId(String userId) throws Exception;
/**
* 유저 패스워드 확인
* @param userId
* @param password
* @return
* @throws Exception
*/
public User matchUserPassword(String userId, String password) throws Exception;
/**
* 유저 등록
* @param userId
* @param userName
* @param password
* @return
* @throws Exception
*/
public int registUser(String userId, String userName, String password) throws Exception;
}
package com.codejcd.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.codejcd.common.CustomException;
import com.codejcd.common.CustomPasswordEncoder;
import com.codejcd.common.MessageProperties;
import com.codejcd.dao.UserDao;
import com.codejcd.entity.User;
@Service
public class UserServiceImpl implements UserService{
@Autowired
private CustomPasswordEncoder passwordEncoder;
@Autowired
private UserDao userDao;
@Override
public User selectUserByUserId(String userId) throws Exception {
return userDao.selectUserByUserId(userId);
}
@Override
public User matchUserPassword(String userId, String password) throws Exception {
User user = userDao.selectUserByUserId(userId);
boolean result = passwordEncoder.matches(password, user.getPassword());
if (!result) {
throw new CustomException(MessageProperties.prop("error.code.userPassword.invalid")
, MessageProperties.prop("error.message.userPassword.invalid"));
}
return user;
}
@Override
public int registUser(String userId, String userName, String password) throws Exception {
password = passwordEncoder.encode(password);
return userDao.registUser(userId, userName, password);
}
}
- selectUserByUserId 메서드
유저 ID를 매개변수로 받아서
DAO를 호출하여 유저 정보를 조회하는 메서드. - matchUserPassword 메서드
유저 정보를 매개변수로 받아서 DAO를 호출하여
저장된 유저 정보와 비교하여 패스워드를 일치 여부를 확인하는 메서드. - registUser 메서드
유저 정보를 매개변수로 받아서 DAO를 호출하여 등록하는 메서드.
15. Client, Token, User Controller
RequestMapping을 통한 Service 호출을 위한 클래스입니다.
RestController 어노테이션을 통해 Bean 등록을 하고 JSON 포맷으로 데이터를 리턴합니다.
15.1 Client Controller
package com.codejcd.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.codejcd.common.CustomException;
import com.codejcd.common.MessageProperties;
import com.codejcd.common.Response;
import com.codejcd.service.ClientService;
/**
* Client 컨트롤러
*/
@RestController
public class ClientController {
@Autowired
private ClientService clientService;
/**
* Client 등록
* @param clientId
* @param clientSecret
* @return
*/
@RequestMapping("/client/regist")
public Response clientRegist(@RequestParam(value="clientId", defaultValue="") String clientId,
@RequestParam(value="clientSecret", defaultValue="") String clientSecret) {
Response response = new Response();
try {
clientService.registClient(clientId, clientSecret);
response.setResponseCode(MessageProperties.prop("error.code.common.success"));
response.setResponseMessage(MessageProperties.prop("error.message.common.success"));
} catch(CustomException e) {
response.setResponseCode(MessageProperties.prop(e.getErrorCode()));
response.setResponseMessage(MessageProperties.prop(e.getErrorMessage()));
} catch(Exception e) {
response.setResponseCode(MessageProperties.prop("error.code.common.occured.exception"));
response.setResponseMessage(MessageProperties.prop("error.message.common.occured.exception"));
e.printStackTrace();
} finally {
}
return response;
}
}
- /client/regist
ClientService의 registClient를 호출하여 Request로 전달받은 Client 데이터를 매개변수로 넘겨줍니다.
Exception이 없으면 정상 응답 코드가 세팅되고, Exception 발생 시 try~catch~finally 문에 의해 처리됩니다.
이번 편에서는 별도의 테스트 데이터 SQL이 없으므로 해당 API를 호출하여 client 값을 생성합니다.
15.2 Token Controller
package com.codejcd.controller;
import java.util.HashMap;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.codejcd.common.CustomException;
import com.codejcd.common.MessageProperties;
import com.codejcd.common.Response;
import com.codejcd.entity.User;
import com.codejcd.service.ClientService;
import com.codejcd.service.TokenService;
import com.codejcd.service.UserService;
@RestController
public class TokenController {
@Autowired
private TokenService tokenService;
@Autowired
private UserService userService;
@Autowired
private ClientService clientService;
/**
* 토큰 발급
* token generation
* @param authorization
* @param userId
* @param password
* @return
*/
@RequestMapping("/oauth2/tokens")
public Response generateToken(@RequestHeader(value="Authorization") String authorization,
@RequestParam(value="userId", defaultValue="") String userId,
@RequestParam(value="password", defaultValue="") String password) {
Response response = new Response();
HashMap<String, Object> result = new HashMap<>();
try {
// Basic Auth 검증
clientService.checkClientByClientId(authorization);
// User 검증
User user = userService.matchUserPassword(userId, password);
// 토큰 생성
result = tokenService.getTokens(user);
response.setResponseCode(MessageProperties.prop("error.code.common.success"));
response.setResponseMessage(MessageProperties.prop("error.message.common.success"));
response.setResult(result);
} catch(CustomException e) {
response.setResponseCode(MessageProperties.prop(e.getErrorCode()));
response.setResponseMessage(MessageProperties.prop(e.getErrorMessage()));
} catch(Exception e) {
response.setResponseCode(MessageProperties.prop("error.code.common.occured.exception"));
response.setResponseMessage(MessageProperties.prop("error.message.common.occured.exception"));
e.printStackTrace();
} finally {
}
return response;
}
/**
* 토큰 연장
* token refresh
* @param authorization
* @param refreshToken
* @return
*/
@RequestMapping("/oauth2/accessToken/refresh")
public Response refreshToken(@RequestHeader(value="Authorization") String authorization,
@RequestParam(value="refreshToken", defaultValue="") String refreshToken) {
Response response = new Response();
HashMap<String, Object> result = new HashMap<>();
String accessToken = "";
try {
clientService.checkClientByClientId(authorization);
accessToken = tokenService.refreshAccessToken(refreshToken);
response.setResponseCode(MessageProperties.prop("error.code.common.success"));
response.setResponseMessage(MessageProperties.prop("error.message.common.success"));
result.put("accessToken", accessToken);
response.setResult(result);
} catch(CustomException e) {
response.setResponseCode(e.getErrorCode());
response.setResponseMessage(e.getErrorMessage());
} catch(Exception e) {
response.setResponseCode(MessageProperties.prop("error.code.common.occured.exception"));
response.setResponseMessage(MessageProperties.prop("error.message.common.occured.exception"));
} finally {
}
return response;
}
}
- /oauth2/tokens
ClientService, userService를 호출하여 각각 Request로 전달받은 Client 정보와 User 정보를 검증한다.
이후 tokenService의 getTokens 메서드를 user 정보를 매개 변수로 하여 호출하여
액세스 토큰을 발급받아 리턴합니다. - /oauth2/accessToken/refresh
Client 정보와 리프레시 토큰을 Request로 전달받아 검증 후 유효하면 Access Token을 리턴합니다.
15.3 User Controller
package com.codejcd.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.codejcd.common.CustomException;
import com.codejcd.common.MessageProperties;
import com.codejcd.common.Response;
import com.codejcd.service.UserService;
@RestController
public class UserController {
@Autowired
private UserService userService;
/**
* 유저 등록
* @param userId
* @param userName
* @param password
* @return
*/
@RequestMapping("/user/regist")
public Response clientRegist(@RequestParam(value="userId", defaultValue="") String userId,
@RequestParam(value="userName", defaultValue="") String userName,
@RequestParam(value="password", defaultValue="") String password) {
Response response = new Response();
try {
userService.registUser(userId, userName, password);
response.setResponseCode(MessageProperties.prop("error.code.common.success"));
response.setResponseMessage(MessageProperties.prop("error.message.common.success"));
} catch(CustomException e) {
response.setResponseCode(MessageProperties.prop(e.getErrorCode()));
response.setResponseMessage(MessageProperties.prop(e.getErrorMessage()));
} catch(Exception e) {
response.setResponseCode(MessageProperties.prop("error.code.common.occured.exception"));
response.setResponseMessage(MessageProperties.prop("error.message.common.occured.exception"));
e.printStackTrace();
} finally {
}
return response;
}
}
- /user/regist
Request로 전달받은 유저 데이터를 userService의 registerUser를 호출하여 매개변수로 전달하여
유저 정보를 등록하고 결과를 리턴합니다.
이번 편에서는 유저 생성하는 SQL 이 없으므로 해당 API를 호출하여 유저 정보를 등록합니다.
16. 테스트
Client와 User 정보 등록 후 Token이 정상 발급되는지 확인해보겠습니다.
테스트 툴로는 POSTMAN을 사용했습니다.
http://localhost:8095/client/regist API를 body 정보를 세팅하고 호출합니다.
정상적으로 처리되어 200 OK 응답과 함께 커스텀 코드와 메시지를 확인 가능합니다.
http://localhost:8095/user/regist API를 body 정보를 세팅하고 호출합니다.
정상적으로 처리되어 200 OK 응답과 함께 커스텀 코드와 메시지를 확인 가능합니다.
http://localhost:8095/oauth2/tokens API를 등록하지 않은 값을 세팅하고 호출해봅니다.
커스텀 Exception 처리한 메시지를 확인 가능합니다.
이번에는 잘못 등록한 값을 제대로 세팅하고 호출합니다.
정상적으로 토큰이 발급된 것을 확인 가능합니다.
블로그에서는 간단하게 테스트하는 방법을 보여드렸고,
실제로는 여러 가지 상황을 가정해서 테스트 케이스를 만들고
더 상세하게 테스트해봐야 합니다.
17. 마치며
이번 글에서는 Spring Security Oauth 없이
Basic Auth에 대한 처리와 Access, Refresh Token을 생성하는 기능을 구현해봤습니다.
다음 글에서는 Resource Server를 구현해보고 이상 이번 시리즈를 마치겠습니다.
'Spring Boot Framework' 카테고리의 다른 글
Spring Rest Docs 와 Swagger (1) (0) | 2022.02.11 |
---|---|
Spring Boot Oauth 2.0 과 JWT Demo (5) (0) | 2022.02.08 |
Spring Boot Oauth 2.0 과 JWT Demo (3) (0) | 2022.02.04 |
Spring Boot Oauth 2.0 과 JWT Demo (2) (0) | 2022.01.30 |
Spring Boot Oauth 2.0 과 JWT Demo (1) (0) | 2022.01.25 |