0322 - 0328
0322
JWT (JSON Web Tokens)
JSON 객체를 사용해서 토큰 자체에 정보들을 저장하고 있는 Web Token
Header
- Signature를 해싱하기 위한 알고리즘 정보
Payload
- 서버와 클라이언트가 주고받는, 시스템에서 실제로 사용될 정보
Signature
- 토큰의 유효성 검증을 위한 문자열
- 이 문자열을 통해 서버에서는 이 토큰이 유효한 토큰인지 검증할 수 있다.
장점
- 세션/쿠키 방식은 별도의 저장소의 관리가 필요하다. 그러나 JWT는 발급한 후 검증만 하면 되기 때문에 추가 저장소가 필요 없어서 stateless 한 서버를 만들수 있다.
- 서버를 확장하거나 유지, 보수하는데 유리하다.
- 토큰 기반으로 하는 다른 인증시스템에 접근이 가능하다. 예를들어 Google 로그인 등은 모두 토큰을 기반으로 인증한다.
단점
- Payload의 정보가 많아지면 네트워크 사용량 증가, 데이터 설계 고려 필요
- 토큰이 클라이언트에 저장되어 서버에서 클라이언트 토큰을 조작할 수 없음
JWT 인증방식
- 먼저 브라우저에서 Login요청을 한다.
- 서버에서는 계정정보를 읽어 사용자를 확인한 후, 사용자의 고유한 ID값을 부여한 후, 기타 정보와 함께 Payload에 넣는다.
- JWT 토큰의 유효기간을 설정한다.
- 암호화할 secret key를 이용해 ACCESS TOKEN을 발급한다.
- 사용자는 토큰을 받아 저장한 후, 인증이 필요한 요청마다 토큰을 헤더에 실어 보낸다.
- 서버에서는 해당 토큰의 verify signature를 secret key로 복호화한 후, 조작 여부, 유효기간을 확인한다.
- 검증이 완료된다면, Payload를 디코딩하여 사용자의 ID에 맞는 데이터를 가져온다.
간단 사용법
대부분의 복잡한 것들은 빌더 기반의 유용한 인터페이스 뒤에 숨겨져 있으며, 빠르게 코드를 작성하는 데 적합
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
String jws = Jwts.builder().setSubject("Joe").signWith(key).compact();
- 서브젝트가 세팅된 등록된 클레임을 가진 JWT를 빌딩한다.
- SHA-256 알고리즘으로 JWT를 서명한다.
- 문자형태로 컴팩팅한다. 서명된 JWT를 JWS라 부른다.
Signed JWTs
JWT 사양은 JWT에 암호화 방식으로 서명하는 기능을 제공한다.
- JWT가 우리가 아는 사람에 의해 생성되었음을 보장한다.
- JWT가 생성 된 후 아무도 JWT를 조작하거나 변경하지 않았음을 보장한다.
JWT 서명 과정
{
"alg": "HS256"
}
String header = '{"alg":"HS256"}'
String claims = '{"sub":"Joe"}'
String encodedHeader = base64URLEncode( header.getBytes("UTF-8") )
String encodedClaims = base64URLEncode( claims.getBytes("UTF-8") )
String concatenated = encodedHeader + '.' + encodedClaims
Key key = getMySecretKey()
byte[] signature = hmacSha256( concatenated, key )
String jws = concatenated + '.' + base64URLEncode( signature )
- JSON header와 body(Claims)가 있는 JWT가 있다고 가정하면,
- JSON에서 불필요한 공백을 제거하고, UTF-8 바이트와 Base64URL 인코딩을 각각 가져온다.
- 인코딩 된 헤더와 클레임을 그들 사이에 마침표 문자로 연결한다.
- 선택한 서명 알고리즘과 함께 암호화 비밀 또는 개인 키를 사용하고 연결된 문자열에 서명한다.
- 서명은 항상 바이트 배열이기 때문에 Base64는 서명을 URL 인코딩하고 마침표 문자 ‘.’를 추가한다.
- 이것을 JWS라고 하며, 서명된 JWT의 약자이다.
- 물론 코드에서 수동으로 이 작업을 수행하지 않는다.
비밀 키 생성 - SHA-256 사용
- 만약에, JWT HMAC-SHA 알고리즘을 사용하기 위해서 충분히 강력한 SecretKey를 생성하기 원한다면, Keys.secretKeyFor(SignatureAlgorithm) 메서드를 사용한다.
SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256); //or HS384 or HS512
- 만약에 사용자가 이 새로운 SecretKey를 저장하길 원한다면, Base64 (혹은 BaSE64URL) 인코딩을 사용할 수 있다.
String secretString = Encoders.BASE64.encode(key.getEncoded());
- Base64 인코딩은 암호화가 아니므로 여전히 민감한 정보로 간주되어 안전한 곳에 저장해야 한다.
0323
JWS 생성
String jws = Jwts.builder() // (1)
.setSubject("Bob") // (2)
.signWith(key) // (3)
.compact(); // (4)
- Jwts.builder() 메서드를 사용하여 JwtBuilder 인스턴스를 생성한다.
- 원하는대로 헤더 매개 변수와 클레임을 추가하는 메서드를 호출한다.
- JWT에 서명하는 데 사용할 SecretKey를 지정한다.
- compact() 메서드를 호출하여 압축하고 서명하여 최종 JWS를 생성한다.
헤더 매개 변수
JWT 헤더는 JWT의 클레임과 관련된 콘텐츠, 형식 및 암호화 작업에 대한 메타 데이터를 제공한다.
Map<String,Object> header = getMyHeaderMap(); //implement me
String jws = Jwts.builder()
.setHeader(header)
// ... etc ...
- 헤더를 설정하는 데는 헤더 인스턴스를 사용하는 방법과, 헤더 맵을 사용하는 방법이 있는데 나는 한 번에 처리할 수 있는 헤더 맵을 사용하여 설정하였다.
Claims
클레임은 JWT의 본문(페이로드)이며 JWT 작성자가 JWT 수신자에게 제공하기를 원하는 정보를 포함한다.
표준 클레임
setIssuer
: 발행자 클레임을 설정한다.setSubject
: 주제를 설정한다.setAudience
: 받는 사람을 설정한다.setExpiration
: 만료 시간을 설정한다.setNotBefore
: 동작하지 않는 시간을 설정한다.setIssuedAt
: 발급 시간을 설정한다.setId
: 아이디를 설정한다.
Map<String,Object> claims = getMyClaimsMap(); //implement me
String jws = Jwts.builder()
.setClaims(claims)
// ... etc ...
Signing Key
- JwtBuilder의
signWith
메서드 를 호출하여 서명 키를 지정하고 JJWT가 지정된 키에 허용되는 가장 안전한 알고리즘을 결정 하도록하는 것이 좋다.
String jws = Jwts.builder()
// ... etc ...
.signWith(key) // <---
.compact();
- signWith JJWT를 사용할 때 alg관련 알고리즘 식별자와 함께 필요한 헤더도 자동으로 설정됩니다 .
SecretKey 형식
- 만약에 사용자가 HMAC-SHA 알고리즘으로 JWS를 서명하기 원하고, 사용자가 String 형식 혹은 인코딩 된 byte 배열 형태의 비밀 키를 가지고 있다면, 사용자는 그것을 signWith 메서드 매개변수로서 사용하기 위해 ScreteKey 객체로 변환해야 한다.
Reading a JWS
- JwtParserBuilder 객체를 생성하기 위해서 Jwts.parseBuilder() 메서드를 사용한다.
- JWS 서명을 증명하기 위해 사용하고 싶은 SecretKey 혹은 비대칭 PubliKey를 명세한다.
- 쓰레드에 안전한 JwtParser를 리턴하기 위해 JwtParserBuilder에 build() 메서드를 호출한다.
- parseClaimsJws(String) 메서드를 원본 JWS를 만드는 jws String와 함께 호출한다.
- 파싱이나 서명 유효성이 실패하는 경우에 try/catch 블록 안에서 모든 호출이 래핑된다.
Jws<Claims> jws;
try {
jws = Jwts.parserBuilder() // (1)
.setSigningKey(key) // (2)
.build() // (3)
.parseClaimsJws(jwsString); // (4)
// we can safely trust the JWT
catch (JwtException ex) { // (5)
// we *cannot* use the JWT as intended by its creator
}
- 주의: 만약 JWS를 기대한다면, 항상 JwtParser의 parseClaimsJws 메서드를 호출하여라.
검증 키
- JWS를 읽을 때 가장 중요한 것은 JWS의 암호화된 signature을 증명하기 위해서 키를 명세하는 것.
- signature 증명이 실패하면, JWT는 안전하게 신뢰될 수 없으며 폐기 되어야만 한다.
- 만약 jws가 SecretKey로 서명되었다면, 같은 SecretKey는 JwtParserBuilder에 명세되어야 한다.
0324
쿠키
동작방식
- 쿠키 생성 단계
- 생성한 쿠키를 응답 데이터의 헤더에 저장하여 웹브라우저에 전송
- 사용자가 request 보내면, 서버가 response하는 시점에 setCookieHeader 라는걸 통해서 response
- 브라우저가 응답 헤더를 보고 쿠키를 만든다.
- 쿠키 저장 단계
- 웹 브라우저는 응답데이터에 포함된 쿠키를 쿠키 저장소에 보관
- 쿠키 전송 단계
- 웹 브라우저는 저장한 쿠키를 요청이 있을 때마다 웹 서버에 전송
- 사용자가 웹서버에 요청할 때마다, 요청한 url에 맞는 저장된 유효한 쿠키가 있는지부터 확인해서 쿠키를 요청시에 함께 보냄
- 웹 서버는 브라우저가 전송한 쿠키를 사용하여 필요한 작업을 수행
쿠키 설정
쿠키 객체 생성
Cookie cookie = new Cookie("key값", "value값");
- 한글을 사용할 시에는 URLEncoder.encode()를 사용하여 인코딩 처리를 해준다.
쿠키 유효 시간 설정
cookie.setMaxAge(60 * 60 * 24);
- 초 단위로 지정한다.
- 지정하지 않으면 브라우저를 종료할 때 쿠키를 함께 삭제한다.
패스
cookie.setPath("/");
- 쿠키는 쿠키 데이터를 생성한 웹 페이지에서만 데이터를 읽을 수 있지만, Path를 지정해주면 그 이하의 경로에서 쿠키 데이터를 공유할 수 있다.
도메인
cookie.setDomain("");
- 패스가 하나의 사이트에서 쿠키 데이터를 읽고 쓰는 권한을 설정하는 것이라면, 도메인 항목은 도메인 단위에서 쿠키 데이터를 읽고 쓰는 권한을 설정하게 된다.
보안
cookie.setHttpOnly(true);
- HttpOnly는 자바스크립트의 document.cookie를 이용 해 쿠키에 접속하는 것을 막는 옵션이다.
cookie.setSecure(true)
- 웹 브라우저와 웹 서버가 HTTPS로 통신하는 경우에만 웹 브라우저가 쿠키를 서버로 전송하는 옵션이다.
응답 헤더에 쿠키 객체를 추가
response.addCookie(cookie);
쿠키 조회
Cookie[] cookies = request.getCookies();
if(cookies != null) {
for(Cookie cookie: cookies) {
System.out.println("쿠키 명: " + cookie.getName());
System.out.println("쿠키 값: " + cookie.getValue());
}
}
쿠키 삭제
Cookie[] cookies = request.getCookies();
if(cookies != null) {
for(Cookie cookie: cookies) {
cookie.setMaxAge(0); // 유효시간을 0으로 설정
response.addCookie(cookie); // 응답 헤더에 추가
}
}
0325
Mabatis
SelectKey
INSERT 수행 이전, 이후에 생성하거나 알 수 있는 값이 필요한 경우 사용
- DB에 명령을 한 번만 보내어, 입력한 값으 결과값을 다음 쿼리로 바로 return 시켜준다.
속성
- keyProperty
-> selectKey 구문의 결과가 세팅될 대상 프로퍼티 - keyColumn
-> 리턴되는 결과셋의 칼럼명은 프로퍼티에 일치한다. 여러 개의 컬럼을 사용한다면 컬럼명의 목록은 콤마를 사용해서 구분 - resultType
-> 결과의 타입. String을 포함하여 키로 사용될 수 있는 간단한 타입을 허용한다. - order
-> BEFORE 또는 AFTER를 세팅할 수 있다. _ BEFORE로 설정하면 키를 먼저 조회하고 그 값을 keyProperty에 세팅한 뒤 insert 구문을 실행 _ AFTER로 설정하면 insert 구문을 실행한 뒤 selectKey 구문을 실행한다.
Insert 후 ID 받아오기
- LAST_INSERT_ID()를 사용하면 된다.
<!-- 게시물 작성 -->
<insert id="save" parameterType="Board">
INSERT INTO BOARD
(
MEMBER_NO,
TITLE,
CONTENT,
REG_DATE
)
VALUES
(
#{memberNo},
#{title},
#{content},
NOW()
)
<selectKey keyProperty="boardNo" resultType="long" order="AFTER">
SELECT LAST_INSERT_ID()
</selectKey>
</insert>
- 이 때, 리턴값은 parameterType에 넘겨준 객체에 넘어간다.
- 즉, 위에서는 이 메소드를 호출한 클래스에서 파라미터로 넘긴 board에서 바로 board.getBoardNo()로 해당값을 가져올 수 있다.
0326
게시글 생성 API 작성
컨트롤러 - 서비스 - 매퍼
// Controller
@RequiredArgsConstructor
@RestController
@RequestMapping("/boards")
public class BoardController {
private final BoardService boardService;
/**
* 게시글 생성
* @param board
* @param request
* @return
*/
@PostMapping("/new")
public BaseResponse insertBoard(@RequestBody Board board, HttpServletRequest request) {
// 글제목 필수 체크
if (isEmpty(board.getTitle())) {
throw new BaseException(BaseResponseCode.CODE_100, new String[]{"글제목"});
}
// 글내용 필수 체크
if (isEmpty(board.getContent())) {
throw new BaseException(BaseResponseCode.CODE_100, new String[]{"글내용"});
}
// request에 저장한 id를 받아와서 Board 객체에 값을 넣어준다.
long memberNo = getMemberNo(request);
board.setMemberNo(memberNo);
long boardNo = boardService.insertBoard(board);
Map<String, Object> ret = new HashMap();
ret.put("boardNo", boardNo);
return new BaseResponse(ret);
}
}
// Service
public interface BoardService {
long insertBoard(Board board);
}
// ServiceImpl
@Service
@Transactional
@RequiredArgsConstructor
public class BoardServiceImpl implements BoardService {
private final BoardMapper boardMapper;
@Override
public long insertBoard(Board board) {
boardMapper.save(board);
return board.getBoardNo();
}
}
// Mapper
@Repository
@Mapper
public interface BoardMapper {
long save(Board board);
}
- 제목과 내용은 필수로 입력받도록 해서 없을 경우 에러 메시지를 반환하도록 처리하였다.
- jwt 토큰 처리를 해서 request에 사용자 id를 저장해두고 받아와서 새 게시글 db에 사용자 정보가 삽입될 수 있도록 했다.
- 반환 값으로는 게시글 번호를 반환하여 reponse로 보내주었다.
mybatis query
<!-- 게시글 작성 -->
<insert id="save" parameterType="Board">
INSERT INTO BOARD
(
MEMBER_NO,
TITLE,
CONTENT,
REG_DATE
)
VALUES
(
#{memberNo},
#{title},
#{content},
NOW()
)
<selectKey keyProperty="boardNo" resultType="long" order="AFTER">
SELECT LAST_INSERT_ID()
</selectKey>
</insert>
<selectKey>
를 사용하여 반환 값으로 pk 값을 반환해주도록 하였다.
0327
게시글 조회 API 작성
게시글을 클릭했을 때 정보들을 프론트 단에서 사용하기 위해 response 값으로 반환해주기 위한 상세 조회 api를 작성하였다.
컨트롤러 - 서비스 - 매퍼
// Controller
/**
* 게시글 세부 조회
* @param boardNo
* @return
*/
@GetMapping("/{boardNo}")
public BaseResponse getBoard(@PathVariable("boardNo") long boardNo) {
Board info = boardService.getBoard(boardNo);
Map<String, Object> ret = new HashMap<>();
ret.put("info", info);
return new BaseResponse(ret);
}
- path에 게시글 번호를 지정하여 그에 맞는 정보를 반환하도록 url을 설계하였다.
boardService.getBoard(boardNo)
는 boardNo로 조회하여 그에 대한 게시물 정보를 반환한다.
mybatis query
<!-- 게시글 조회 -->
<select id="getBoard" parameterType="long" resultType="Board">
SELECT
B.BOARD_NO,
A.MEMBER_NO,
A.MEMBER_NAME,
B.TITLE,
B.CONTENT,
B.COMMENT_CNT,
B.LIKE_CNT,
B.REG_DATE
FROM MEMBER A INNER JOIN BOARD B ON A.MEMBER_NO = B.MEMBER_NO
WHERE BOARD_NO = #{boardNo}
</select>
- BOARD 테이블에 없는 MEMBER_NAME 컬럼을 출력하기 위해 MEMBER 테이블과 BOARD 테이블을 INNER JOIN하였다.
- 예를 들어,
http://localhost:8080/boards/3
으로 호출하게 되면 아래와 같은 결과 값을 반환받게 된다.
{
"code": "CODE_200",
"message": "정상 처리",
"data": {
"info": {
"boardNo": 3,
"memberNo": 47,
"memberName": "홍길동",
"title": "제목테스트수정",
"content": "내용테스트수정",
"commentCnt": 0,
"likeCnt": 0,
"regDate": "2021-03-28 14:25:01"
}
}
}
0328
게시글 수정, 삭제 API 작성
게시글 수정 API
/**
* 게시글 수정
* @param boardNo
* @param board
* @param request
* @return
*/
@PostMapping("/{boardNo}/edit")
public BaseResponse updateBoard(@PathVariable("boardNo") long boardNo, @RequestBody Board board, HttpServletRequest request) {
// 글제목 필수 체크
if (isEmpty(board.getTitle())) {
throw new BaseException(BaseResponseCode.CODE_100, new String[]{"글제목"});
}
// 글내용 필수 체크
if (isEmpty(board.getContent())) {
throw new BaseException(BaseResponseCode.CODE_100, new String[]{"글내용"});
}
// 현재 로그인한 회원 번호와 게시글 작성한 회원 번호를 비교해서 다른 경우에 예외 발생
Board info = boardService.getBoard(boardNo);
long memberNo = getMemberNo(request);
if (memberNo != info.getMemberNo()) {
throw new BaseException(BaseResponseCode.CODE_103);
}
board.setBoardNo(boardNo);
boardService.updateBoard(board);
return new BaseResponse();
}
게시글 삭제 API
/**
* 게시글 삭제
* @param boardNo
* @param request
* @return
*/
@PostMapping("/{boardNo}/delete")
public BaseResponse deleteBoard(@PathVariable("boardNo") long boardNo, HttpServletRequest request) {
// 현재 로그인한 회원 번호와 게시글 작성한 회원 번호를 비교해서 다른 경우에 예외 발생
Board info = boardService.getBoard(boardNo);
long memberNo = getMemberNo(request);
if (memberNo != info.getMemberNo()) {
throw new BaseException(BaseResponseCode.CODE_103);
}
boardService.deleteBoard(boardNo);
return new BaseResponse();
}
mybatis query
<!-- 게시글 수정 -->
<update id="updateBoard" parameterType="Board">
UPDATE BOARD
SET
TITLE = #{title},
CONTENT = #{content}
WHERE BOARD_NO = #{boardNo}
</update>
<!-- 게시글 삭제 -->
<delete id="deleteBoard" parameterType="long">
DELETE FROM BOARD
WHERE BOARD_NO = #{boardNo}
</delete>
- 게시글을 수정하고 삭제할 수 있는 API를 작성하였다.
- 현재 로그인한 회원 정보와 요청 받은 게시물을 작성한 회원 정보를 비교해서 다른 경우에는 예외를 발생시켰다.