개발 글을 5개를 썼는데 모두 OAuth에 관한 내용이긴한데 오늘은 어제 만든 리프레시 토큰을 통한 액세서 토큰 만료 시 재발급 코드만 짜면 되기 때문에 ㄱㅊ
1. JWT AccessToken 재발급 마무리
어제 마지막으로 문제가 되었던 부분은
jjwt에서 secret 코드를 통해 해싱하는 함수인 sightWith(algorithm, secret) 메서드가 있는데, 이 메서드는 secret을 byte[]나 String type의 인코딩된 Base64 값만을 받는다고한다.
어제 나는 yml 파일에 String으로 저장된 키를 인코딩 없이 그대로 secret에 넣었다가 오류가 났고, 이제는 secret 키를 처음부터 Base64로 받아와 JWT토큰 발급클래스에 넘겨주면서 오류가 생긴 것.
근데 또 Value 어노테이션은 String 객체를 받아온 후 Base64로 바꾸는 기능이 없더라....
방법은 두 가지인데,
String으로 받아오고 컨트롤러에서 Base64로 변환하기 (솔직히 수정은 적지만 비효율적인 방식)
String으로 받아오고 JWTFilter에서 Base64로 변환하기 (수정 개많이해야함 그러나 효율적)
당연히 후자를 골랐고 무한수정지옥에 들어감
추가로 String을 무작정 Base64로 바꿔도 딱히 에러메시지가 나지 않는다. 그냥 로그에 WARN만 찍힘
문제는 로그인 등의 기능을 수행하면(즉, JWT 토큰과 관련된 기능을 수행하면) 그냥 에러사이트로 이동한다는게 단점
public String createToken(String secret, String identifier, String roles) {
SecretKey key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secret));
Claims claims = Jwts.claims();
claims.put("identifier", identifier);
claims.put("role", roles);
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + validTokenTime))
.signWith(SignatureAlgorithm.HS512, key)
.compact();
}
이런식으로 String으로 시크릿 키를 받고 디코딩하기로 했다.
JWT 인코딩 / 디코딩에 관한 글 --> https://velog.io/@junyoungs7/JWT-%EC%83%9D%EC%84%B1-%EB%B0%8F-%EA%B2%80%EC%A6%9D-%ED%85%8C%EC%8A%A4%ED%8A%B8
JWT 생성 및 검증 테스트
JWT 생성
velog.io
yml 파일에 Base64 기반으로 인코딩된 secret이 있고 그걸 JWTProvider가 받으면 이를 디코딩해서 평문으로 바꾼 다음에 Keys.hmacShaKeyFor() 메서드를 통해 secret에 들어갈 수 있는 형식의 secret key를 JWT에 쓴다.. 뭐 이런 매커니즘이다.
그래서 이렇게 수정하니 로그인 기능도 다시 가능해졌다.
이전에는 JWT 토큰발급과정에서 오류가 생겨서 로그인이 안됐는데.. 다행이다. 또 weakkeyException이 발생했는데 그건 내가 HS512로 JWT 토큰을 발급해서 그런거고.. HS256으로 설정하니까 또 된다. 괜히 유튜브에서 HS256을 쓰라고한게 아니네
어느정도 완료하고 login expired 테스트를 하려는데 오류발생
알고보니 Value 어노테이션으로 secret을 넣어주던게 실패해서 그냥 {spring.jwt.secret}이 secret 키로 들어갔었다. Base64로 디코딩할 때 .과 {}이 없으니까 당연히 오류가 발생, Illegal base64 7b 오류가 났었다.
근데 로그인 기능에서는 Value 어노테이션이 잘만 되던데..
알고보니 Value 어노테이션에 $를 안넣었다.
그거 고치니 잘된다.
근데 난 분명 refresh token의 유효기간을 30일로 설정했는데 왜 만료되었다고 뜨는거지?
알고보니
public String validateToken(String secret, String token) {
SecretKey key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secret));
try {
Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
if (claims.getBody().getExpiration().before(new Date())) {
return "성공";
}
return "오류";
} catch (ExpiredJwtException e) {
return "만료된 JWT";
} catch (SecurityException | MalformedJwtException e) {
return "잘못된 타입";
} catch (UnsupportedJwtException e) {
return "잘못된 JWT 구조";
} catch (io.jsonwebtoken.security.SignatureException e) {
return "서명실패(위조데이터)";
}
}
알고보니 지금시각과 만료시각을 비교하는 코드에서 토큰이 만료될 경우 성공을, 토큰이 정상적일 경우 오류를 반환하도록 코드를 짜버렸다.
public String validateToken(String secret, String token) {
SecretKey key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secret));
try {
Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
if (claims.getBody().getExpiration().before(new Date())) {
return "만료된 JWT";
}
return "성공";
} catch (ExpiredJwtException e) {
return "만료된 JWT";
} catch (SecurityException | MalformedJwtException e) {
return "잘못된 타입";
} catch (UnsupportedJwtException e) {
return "잘못된 JWT 구조";
} catch (io.jsonwebtoken.security.SignatureException e) {
return "서명실패(위조데이터)";
}
}
수정
그랬더니 잘 된다.
대충 이렇게 돌아간다.
마지막에 응답코드와 엑세스토큰을 새로 발급해주면서 마무리
지금은 Refresh Token을 정해줄 방법이 없어서 그냥 선언해두고 실행시키지만 나중이 되면 프론트에서 리프레시 토큰을 전해주던 쿠키에서 따오던 해서 헤더에 담아 백에 넘겨주면 새 AccessToken을 발급하도록 할 것이다.
지금 Refresh Token의 유효기간은 30일, AccessToken의 유효기간은 1시간으로 설정해두었다.
이렇게 로그인 기능의 거진 끝이 났다. 뭐 벡-프 협업 때 수정할 일이 왕창 생기겠지만 우선은 이렇게 뼈대를 세워뒀고..
이제 해야할 일은 홍익대학교 재학생 로그인 기능이다. 인증메일을 발송하는 등의 약간의 번거로움이 있지만 이미 JWT 토큰까지 다 만들어놓은 상황에서 로그인 기능만 구축하면 되기 때문에 그래도 아예 맨땅에 헤딩이었던 구글 OAuth보다는 난이도가 낮지 않을까 싶다.
2. 홍익대학교 재학생 로그인 기능
대충 어떤 방식으로 구현할거냐면..
홍익대 재학생 로그인을 선택한 사람은 oauthprovider가 4로 지정되며 url로 이동함
/api/login/4에서는 홍대 로그인을 위한 입력창으로 이동, 이 때 이메일을 입력받는다.
이메일이 입력되면 api/login/4/email로 이동하여 인증번호를 발급
api/login/4/email/passwor이동한 url에서 유저는 인증번호를 입력하고, 백엔드를 그걸 받아서 확인한다.
그리고... 인증번호를 대조하기 위해서는 어딘가에 '저장'해야하는데
DB는 내가 지금 DB를 안만들어놔서 못하고 Redis에 저장해보려한다.
@Override
public ResponseEntity<Map<String, Object>> hongikUnivlogin() {
Random random = new Random();
int passKey = random.nextInt(8999) + 1000;
Map<String, Object> response = new HashMap<>();
response.put("code", 200);
response.put("redirect-url", "/api/login/hongikUniv");
response.put("pass-key", passKey);
return ResponseEntity
.status(HttpStatus.OK)
.body(response);
}
}
우선 oauthprovider가 4일 때 이동시키는 코드
인증번호와 리다이렉트를 동시에 수행하긴 좀 어려워서 ResponseEntity에 redirect-url와 passkey를 모두 담아서 보냈다.
이거 너무 프론트에게 많은걸 바라는게 아닌가
우선 비밀번호를 발급했고, 그 다음에 해야할 일은 메일로 전송하는 것
이건 프론트에서 넘겨줘야한다. 어떻게 넘길지 저장은 안했는데 그냥 JSON 형식으로 email : ~~ 형식으로 보낼 듯.
메일보내기 - javax.mail
javax.mail 라이브러리를 사용하면 자바로 메일을 보낼 수 있다고한다.
정확히 javax.mail은 SMTP을 이용할 수 있게 해주는 라이브러리인데.. SMTP가 뭘까?
SMTP는 Simple Mail Transfer Protocol (간이 메일 전송 프로토콜)의 약어로, 인터넷의 이메일 송수신에 사용되는 통신 프로토콜이다. 그냥 인터넷에서 이메일 보낼 때 사용되는 프로토콜이라고 보면 될 듯
근데 이건 특별히 자바만에서만 실행되는게 아니라 그냥 네이버메일이나 지메일에서도 쓰는 방식이라고 한다. 즉 이메일을 보내는 상황이 되면 무조건 사용하게 된다는 뜻. 딥하게 들어가면 내용도 무거워지고 시간도 많이 소모하니 대충 기능 정도만 알아두는데,
- 이메일 전송 - 같은 서버 간 통신. aaa@naver.com 에서 bbb@naver.com으로 이메일을 보내는 경우
- SMTP 릴레이 - 서버가 다른 주소 간 이메일 송신. aaa@naver.com에서 ccc@gmail.com 등 다른 서버로 보내는 경우에 사용된다. 네이버나 구글은 자체적으로 SMTP 릴레이 서버를 공급한다. 좀 더 알아보고 싶긴한데 우선은 지금 하던거 먼저
https://medium.com/@jongmin.kim/smtp-relay-fca0d3827cfb
SMTP Relay
SMTP 릴레이란 메일 서버 외부에서 메일 서버를 경유하여 다른 메일 서버로 메일을 보내는 것을 의미한다. 이 때 경유한 서버를 메일 릴레이 서버라 한다.
medium.com
그래도 도움이 될만한 포스
대충 이정도.. 코딩하러 갑시다.
/**
* 보낼 이메일주소 (to) / 제목 (subject), 내용 (text)를 담아 이메일을 보내는 코드입니다.
*/
package com.hongchelin.Service.Email;
import org.springframework.http.ResponseEntity;
import javax.mail.*;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
public class EmailSender {
public ResponseEntity<Map<String, Object>> EmailSenderService(String to, int pwd, String subject, String text) throws Exception {
String from = "dhmoon2006@gmail.com";
String password = "#";
String host = "smtp.gmail.com";
Properties props = new Properties();
props.put("mail.smtp.host", host);
props.put("mail.smtp.port", "587");
props.put("mail.smtp.auth", "true");
props.put("mail.smtp.starttls.enable", "true");
Session session = Session.getInstance(props, new Authenticator() {
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(from, password);
}
});
Message message = new MimeMessage(session);
message.setFrom(new InternetAddress(from));
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(to));
message.setSubject(subject);
message.setText(text);
Transport.send(message);
Map<String, Object> response = new HashMap<String, Object>();
response.put("code", 200);
response.put("message", "인증번호 발송에 성공하였습니다\n"+pwd);
return ResponseEntity.ok(response);
}
}
이메일을 보내는 코드
윗줄부터 코드 뜯어보기를 해보자면..
첫문단은 누구의 이메일주소로 이메일을 보낼 것인가, 그 사람의 이메일 비밀번호는 어떻게 되는가, 누구의 서버를 이용할 것인가(여기서는 구글) 정도고
Properties props = new Properties();
props.put("mail.smtp.host", host);
props.put("mail.smtp.port", "587");
props.put("mail.smtp.auth", "true");
props.put("mail.smtp.starttls.enable", "true");
그 다음 문단은 SMTP 서버 연결을 위한 설정이라고 하는데
호스트를 정하고 (보통 메일 도메인)
연결포트를 정하고 (기본이 25인데 요즘은 587을 사용한다고한다. 포트에 대해서는 나중에 또 공부해야함)
SMTP 인증 사용여부를 정하는데, GPT에게 물어보니 구글의 SMTP 서버 사용을 위해서 내 이메일과 비밀번호로 로그인해 계정이 유효한지 여부를 판단한다고 한다. 유효하지 않은 계정이면 이메일을 발송할 수 없다는거 -> https://kinosox.tistory.com/82 보면 이해에 도움이 되려나
그 밑줄은 starttls (보안 연결) 사용 여부를 묻는다. 보안상 true가 낫다고하며, 내가 587코드를 이용해 STARTTLS 방식을 사용 중이라 true로 설정함
Session session = Session.getInstance(props, new Authenticator() {
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(from, password);
}
});
session이 있는 문단은 보내는 사람의 계정정보를 설정한다고 하는데 메서드는 딱 한 개다. Session.getInstance(). 이는 서버 정보와 함께 내 정보를 저장해두는 것으로, 이메일을 보낼 때 보내는 사람의 계정에 로그인을 해야하니 계정에 로그인해 정보를 저장해둔다. SMTP 서버 (여기서는 구글서버)가 인증정보를 요구하면 PasswordAuthentication에 있던 계정정보가 이동한다.
그 밑에는 그냥 내용, 제목 정하는거고
Transport.send(message);
이러면 이제 보내진다.
GPT에게 물어본 결과 send가 되면
아까 session 객체에 있던 서버정보 (props / 어디에 보내고, 포트는 몇 번이고 등...)를 통해 해당 서버에 연결한다.
연결 했으면 session에 있던 Authenticator 에 있던 내 이메일/아이디를 통해 로그인하고, 서버가 인증함
인증성공 시 전송
대략 이런 플로우로 진행된다고 한다. 저렇게 긴 과정이 단 한 줄. send로 끝나다니 ㄷㄷ
emailSender는 이정도로 됐고.. 이제 보내야겠다.
@Service
public class hongikUnivloginService {
private final EmailSender emailSender;
private final chckStatusCode chckStatusCode;
public hongikUnivloginService(EmailSender emailSender, chckStatusCode chckStatusCode) {
this.emailSender = emailSender;
this.chckStatusCode = chckStatusCode;
}
public ResponseEntity<Map<String, Object>> hongikUnivloginService(String email) throws MessagingException {
Random random = new Random();
Map<String, Object> response = new HashMap<>();
int pwd = random.nextInt(899999) + 100000;
String sub = "홍익대학교 재학생 이메일 로그인 인증요청입니다.";
String text = "안녕하세요.\n저희 홍익대학교 맛집 아카이빙 프로젝트 '홍슐랭'을 이용해주셔서 감사합니다.\n\n인증번호는\n"+pwd+"\n입니다.\n\n감사합니다.";
ResponseEntity<Map<String, Object>> resultsendingEmail = emailSender.EmailSenderService(email, pwd, sub, text);
Integer code = chckStatusCode.chckStatusCode(resultsendingEmail);
if (code == 200) {
return ResponseEntity
.status(HttpStatus.FOUND)
.build();
} else if (code != 900) {
Map<String, Object> body = resultsendingEmail.getBody();
String message = (String) body.get("message");
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(response);
} else {
String message = "오류메시지가 null입니다.";
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(response);
}
}
}
기능은 인증번호를 만들어서 보내는 역할이다.
컨트롤러에서 POST 요청으로 이메일을 받아 넘겨주면 얘가 실행함.
중간에 chckStatusCode가 추가됐는데 이건 ResponseEntity의 응답코드만 빼내주는 코드. 많이 사용할 것 같아서 따로 만들었다.
아마 DTO를 사용하면 더이상 사용하지 않을 것 같은데.. 우선은 두고보기로
실행함

오
오류가 났긴했는데 뭐라도 떠서 다행이다. 그래 이렇게라도 떠야 뭔지알지
원인은 SMTP 인증실패라고한다. 2단계 인증을 켜라고하네... 2단계 인증도 하고 '앱 비밀번호'라는 것도 따로 발급받아서 비밀번호 대용으로 사용해야 SMTP 인증을 통과할 수 있다. 그거 솔직히 귀찮아서 꺼버렸는데
새 계정을 만들고 2단계 인증도 켜고 앱 비밀번호도 발급했더니

와! 왔다!
지금까지 내 개발인생 중에서 가장 뿌듯한 순간인듯
영상으로도 남겼다.
그리고 저걸 검증/비교하는 사이트도 만들어야한다. 근데 그러면 Redis를 통해서 인증번호를 넘겨줘야하고...
우선 거기까지 갈 엄두는 나지 않아서 간단한 기능부터 만들기로 했다.
1. 재학생 전용이메일 구분
우리학교의 이메일주소는 aaa@g.hongik.ac.kr 로 이루어져있다.
그러면 어떻게 재학생 전용이메일인지 구분할까?
그냥 간단하게 split한 뒤에 뒤에 있는걸 따와서 구분하면 된다.
코드 보여줄 것 없이 그냥 앞서 만든 코드를 도메인이 맞을 시 시행하는 if문에 넣고 아닐 경우에만 따로 메시지를 반환하면 끝

가볍게 성공

홍대 재학생 계정으로 계정을 바꾸니 또 성공
와~~
진짜 기쁘다.
우선 이 다음 인증번호를 대조하는 과정은 Redis만 배우면 얼마 안 걸리기도하고 (1~2시간이면 다 만들듯)해서 내일할까 진지하게 고민 중. 내일은 dto 만들어야하는데ㅜㅜ
우선은 협업자들을 위해 javadoc을 통해 주석처리 좀 할까한다.
javadoc
javadoc은 문서 맨 위에 주석처리를하여 이 클래스가 어떤 기능을 하는지 등의 정보를 넣어둔다. 이거는 CRUD 때 배운건데 사용해보니까 보기 편해서 지금고 써먹는 중이다.
문서에 대한 설명을 한 후 어노테이션이라 하나 @로 뭔가를 표시하는데 사용되는데 리스트들은 다음과 같다.
이 글이 친절하게 되었는데 참고해보세용 -> https://agileryuhaeul.tistory.com/entry/Javadoc%EC%9D%B4%EB%9E%80-Javadoc-%EC%82%AC%EC%9A%A9%EB%B0%A9%EB%B2%95
@author - 작성자 표시
@version - 버전 표시
@deprecated - 없어짐. 더이상 사용을 권장하지 않음
@param - 매개변수 설명
@return - 반환값 설명
@throws, @exception - 발생가능한 예외
더 있는데 굳이 쓰지는 않을 것 같아서..
모든 파일들을 순회하며 javadoc을 작성할 예정
다 썼다
모든 클래스들을 순회하면서 쓰니까 한 40분 정도 걸린듯
오늘은 대충 이정도로 끝
내일 할 일
DTO에 대해 배웁시다
Redis를 배우고 (나중에 해도 되니) 발송한 인증번호를 대조할 코드를 짭시다. 그러면 로그인 기능 거의 완성
'팀 프로젝트 > [2025][GDG]홍대 맛집 아카이빙 프로젝트' 카테고리의 다른 글
| [GDG]홍대 맛집 아카이빙 프로젝트 #7 - 투표기능 기획 (2) | 2025.07.20 |
|---|---|
| [GDG]홍대 맛집 아카이빙 프로젝트 백엔드 개발 #6.1 - DTO 및 로그인 기능 마무리 (0) | 2025.07.19 |
| [GDG] 개발코스 2주차 WIL (1) | 2025.07.17 |
| [GDG]홍대 맛집 아카이빙 프로젝트 백엔드 개발 #4 - 오늘은 OAuth 끝내죠? (7) | 2025.07.17 |
| [GDG]홍대 맛집 아카이빙 프로젝트 백엔드 개발 #3 - 구글 OAuth (0) | 2025.07.16 |