✍️ 작성중
- 어떠한 이유로 해당 기능을 사용하였는지
- 해당 기능의 코드는 어떠한 로직을 가지고 있는지
- 코드를 작성하며 발견된 버그나 오류는 어떠한게 있었는지 그리고 어떻게 해결하였는지
- 게시물 조회 + 댓글 조회
- 댓글 수정
- 사용자 인증/인가 기능
🚩 게시물 단건 조회 + 댓글 조회, 게시물 전체 조회
1 어떠한 이유로 해당 기능을 사용하였는지
- 정보 공유가 주목적인 뉴스피드이니 작성된 게시물을 확인할 수 있어야 한다.
- 작성된 게시글에 대한 의견을 주고받을 수 있는 댓글 또한 확인할 수 있어야 한다.
2 해당 기능의 코드는 어떠한 로직을 가지고 있는지 ※ 입력값이 들어가면 어떠한 코드를 통해 어떠한 값으로 변화하는지
- 전체 조회
override fun getAllPostList(): List<PostResponse> {
return postRepository.findAll().map { PostResponse.toPostResponse(it) }
}
컨트롤러단에서 전체 조회를하면
게시글이 있는 repository에서 모든 데이터를 find 한 뒤
PostResponse DTO 형태로 반환한다.
- 단건 조회
override fun getPostById(postId: Long): PostWithReplyResponse {
val post = postRepository.findByIdOrNull(postId) ?: throw ModelNotFoundException("Post", postId)
return PostWithReplyResponse.toPostWithReplyResponse(post)
}
컨트롤러단에서 원하는 게시글의 아이디를 입력하고 조회를 하면
아이디를 기반으로 해당하는 게시글을 find 하고
그 게시글의 id를 가지고 있는 댓글과 함께 PostWithReplyReponse DTO 형태로 반환한다.
3 코드를 작성하며 발견된 버그나 오류는 어떠한게 있었는지 그리고 어떻게 해결하였는지
-
🚩 댓글 수정
1 어떠한 이유로 해당 기능을 사용하였는지
- 작성한 댓글을 수정할 수 있어야 한다.
2 해당 기능의 코드는 어떠한 로직을 가지고 있는지 ※ 입력값이 들어가면 어떠한 코드를 통해 어떠한 값으로 변화하는지
@Transactional
override fun updateReply(replyId: Long, request: UpdateReplyRequest): ReplyResponse {
val reply = replyRepository.findByIdOrNull(replyId) ?: throw ModelNotFoundException("Reply", replyId)
val currentUser = SecurityContextHolder.getContext().authentication.name
if(reply.author != currentUser) throw UnauthorizedAccess()
reply.content = request.content
return ReplyResponse.toReplyResponse(reply)
}
댓글의 아이디를 받아와 해당하는 댓글을 reply 에 담는다.
작성자 - 시큐리티 기능
request 로 받아온 content 를 reply 의 content 로 할당한다.
업데이트한 reply 를 ReplyResponse DTO 형태로 반환한다.
영속성 컨텍스트에 의해 reply 는 업데이트 된 내용으로 데이터베이스에 저장된다.
3 코드를 작성하며 발견된 버그나 오류는 어떠한게 있었는지 그리고 어떻게 해결하였는지
-
🚩 사용자 인증 / 인가
1 어떠한 이유로 해당 기능을 사용하였는지
- 본인이 작성한 게시글과 댓글만 수정, 삭제할 수 있어야 한다.
- 게시글과 댓글을 작성할 때 사용자의 아이디가 자동으로 같이 저장된다.
- 이를 위해서 우선 회원가입과 로그인 기능으로 사용자를 인증할 수 있어야 하고
- 로그인 하지 않은 사람은 게시글의 조회만 할 수 있어야 한다.
- 이를 위해서 토큰을 이용한 인가가 필요했다.
2 해당 기능의 코드는 어떠한 로직을 가지고 있는지 ※ 입력값이 들어가면 어떠한 코드를 통해 어떠한 값으로 변화하는지
도움을 받은 블로그 (1 ,
- 회원가입
@Transactional
fun registerMember(request: SignUpRequest): SignUpResponse {
val isExistAccount = userRepository.existsByEmail(request.email)
if (isExistAccount) throw DuplicateUsernameException(request.email) // 이메일(아이디) 중복 검사
return SignUpResponse.toSignUpResponse(userRepository.save(User.toUser(request, encoder))) // encoder 가져와서 비밀번호 암호화해서 저장
}
request의 아이디(email) 와 동일한 유저 정보가 있는지 userRepository 에서 찾고 있다면 중복된 이메일이라는 메시지를 날린다. 없다면 request 정보를 encoder 와 함께 가져와서 User 객체로 만든 뒤 userRepository 에 저장한다.
PasswordEncoder 인 encoder 는 다음과 같이 데이터를 암호화한다.
companion object {
fun toUser(request: SignUpRequest, encoder: PasswordEncoder) = User(
email = request.email,
userPassword = encoder.encode(request.userPassword), // 비밀번호 암호화
name = request.name
)
}
- 로그인
@Transactional
fun login(request: SignInRequest): SignInResponse {
val user = userRepository.findByEmail(request.email)
.takeIf { encoder.matches(request.password, it.userPassword) } // .matches() 암호화되지 않은 비밀번호와 암호화되어 저장된 비밀번호를 검사해준다.
?: throw MisMatchedExcpetion(request.email)
val token = tokenProvider.createToken("${user.email}:${user.type}") // 사용자 정보로 토큰 생성
return SignInResponse(user.email, token)
}
request 로 받은 아이디를 userRepository 에서 찾고 그 아이디에 해당하는 비밀번호와 request 로 받은 비밀번호를 검사한다. 일치한다면 해당 사용자의 아이디와 타입을 이용해서 토큰을 생성한 후 아이디와 함께 토큰을 반환해준다.
토큰 생성
@PropertySource("classpath:jwt.yml") // 설정 파일 경로
@Service
class TokenProvider(
@Value("\${secret-key}")
private val secretKey: String,
@Value("\${expiration-hours}")
private val expirationHours: Long,
@Value("\${issuer}")
private val issuer: String
)
fun createToken(userSpecification: String) = Jwts.builder()
.signWith(io.jsonwebtoken.SignatureAlgorithm.HS256, Base64.getEncoder().encodeToString(secretKey.toByteArray()))
.setSubject(userSpecification) // 고유 정보로 제목 설정
.setIssuer(issuer) // 발급자
.setIssuedAt(Timestamp.valueOf(LocalDateTime.now())) // 발급 시간
.setExpiration(Date.from(Instant.now().plus(expirationHours, ChronoUnit.HOURS))) // 토큰 만료 설정
.compact()!!
유저정보(userSpecification) 를 받아와서 이를 토대로 토큰을 생성한다.
Jwt.builder() : JWT 를 생성하기 위한 빌더 생성
signwith() : 알고리즘과 서명키를 지정
이 때 서명키는 Base64로 인코딩한 값을 가져와야하므로 Base64.getEncoder().encodetoString() 으로 인코딩해줬다.
setSubject() : 토큰 제목 설정. 여기서는 user의 email 과 type 을 가져왔다.
setIssuer() : 토큰 발급자
setIssuedAt() : 토큰 발급 시간. 현재 시간을 사용하고 있다.
setExpiration() : 토큰 만료시간. 현재시간에 설정해놓은 만료시간을 더해서 설정하고 있다.
compact() : 위의 옵션을 기반으로 생성하고 문자열로 반환.
게시글 작성
@Transactional
override fun createPost(request: CreatePostRequest): PostResponse {
val currentUser = SecurityContextHolder.getContext().authentication.name
val post = postRepository.save(request.toPost(currentUser))
return PostResponse.toPostResponse(post)
}
사용자가 게시글을 작성, 수정, 삭제할 때 클라이언트는 엑세스 토큰을 HTTP 요청 헤더에 포함하여 작성 요청을 서버에 보낸다.
spring security 는 요청을 받아들이고, 엑세스 토큰을 추출하여 토큰의 유효성을 확인한다.
토큰이 유효하면 사용자 정보를 컨텍스트에 저장한다.
그리고 그 사용자 정보를 SecurityContextHolder.getContext().authentication 에서 가져와서 게시글과 연결시킨다.
❓ SecurityContextHolder
: 인증 객체가 저장되는 보관소
인증 정보가 필요할 때 언제든지 인증 객체를 꺼내서 사용할 수 있다. 코드의 아무곳에서나 참조할 수 있고 다른 스체드와 공유하지 않으므로 독립적으로 사용할 수 있다.
게시글 수정
@Transactional
override fun updatePost(postId: Long, request: UpdatePostRequest): PostResponse {
val savedPost = postRepository.findByIdOrNull(postId) ?: throw ModelNotFoundException("Post", postId)
val currentUser = SecurityContextHolder.getContext().authentication.name
if (savedPost.author != currentUser) throw UnauthorizedAccess()
savedPost.updatePost(request)
return PostResponse.toPostResponse(savedPost)
}
게시글이나 댓글을 수정, 삭제 할 때도 마찬가지로 현재 사용자 정보를 가져와서 비교한다.
요청받은 토큰의 필터링 JwtAuthenticalFilter : OncePerRequestFilter()
// 헤더에서 토큰 추출
private fun parseBearerToken(request: HttpServletRequest)
= request
.getHeader(HttpHeaders.AUTHORIZATION) // http 요청의 헤더에서 authorizarion 값을 찾아서
.takeIf { it?.startsWith("Bearer ", true) ?: false } // 접두어 확인, 제거 ( Bearer 타입 )
?.substring(7)
HTTP 헤더의 Authorization 에서토큰을 추출한다.
이 때 지금 사용중인 토큰인 Bearer 를 확인하고 제거해서 실제 토큰을 가져온다.
❓Bearer Token
// 토큰으로 정보 추출
private fun parseUserSpecification(token: String?) = (
token?.takeIf { it.length >= 10 }
?.let { tokenProvider.validateTokenAndGetSubject(it) } // 토큰이 유효한지 확인, 복호화
?: "anonymous:anonymous" // 유효하지 않을 때 설정
).split(":")
.let { User(it[0], "", listOf(SimpleGrantedAuthority(it[1]))) } // 사용자 정보 가져와서 스프링 시큐리티 User 객체 생성
받아온 토큰으로 토큰이 유효한지 확인하고
// 토큰의 subject 를 복호화하여 문자열 형태로 반환 ( 유효한지 확인, 유효하지 않을 경우 null )
fun validateTokenAndGetSubject(token: String): String? = Jwts.parserBuilder()
.setSigningKey(secretKey.toByteArray()) // 비밀키로 복호화
.build()
.parseClaimsJws(token) // 파싱 : jwt 형식에 맞게 데이터를 해석하고 추출
.body // 토큰의 본문 (claims) 에 접근
.subject // 주체 반환
사용자 정보로 스프링 시큐리티의 User 객체를 생성한다.
// 인증 정보 설정
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val token = parseBearerToken(request) // 토큰 추출
val user = parseUserSpecification(token) // 사용자 정보 추출
UsernamePasswordAuthenticationToken.authenticated(user, token, user.authorities) // 인증된 사용자를 나타내는 토큰 생성
.apply { details = WebAuthenticationDetails(request) } // 요청날린 client 또는 프록시의 ip 주소와 세션 id 저장
.also { SecurityContextHolder.getContext().authentication = it } // sercurityContextHolder 에 인증 정보 저장
filterChain.doFilter(request, response) // 다음 필터
}
위에서 가져온 토큰과 사용자 정보, 사용자 권한으로 UsernamePasswordQuthenticationToken 을 생성하고 (이것을 사용하여 사용자를 인증한다) 사용자의 인증 세부정보를 추가해서 SecurityContextHolder 에 이를 저장한다.
3 코드를 작성하며 발견된 버그나 오류는 어떠한게 있었는지 그리고 어떻게 해결하였는지
deprecated
signWith
"이 메서드의 문제점: 키 인자로 문자열이 사용되었는데, 이는 암호화 작업에 사용되는 키가 항상 바이너리 데이터여야 하는데, 이를 문자열에서 어떻게 바이트로 얻는지에 대한 혼동을 야기했습니다."
"올바른 로직 예제: 올바른 방법으로 작업하려면 문자열로 된 키를 먼저 BASE64 디코딩하여 바이트 배열로 변환한 다음, 이를 사용하여 암호화 작업을 수행해야 합니다. 아래는 이를 수행하는 예제 코드입니다"
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
Key key = Keys.hmacShaKeyFor(keyBytes);
jwtBuilder.signWith(key); //or signWith(Key, SignatureAlgorithm
원래는 아래와 같이 secretkey 를 바이트로 변환 후 Base64 로 인코딩한 걸 넣었었다.
.signWith(io.jsonwebtoken.SignatureAlgorithm.HS256, Base64.getEncoder().encodeToString(secretKey.toByteArray()))
문자열로 된 secretKey 를 Base64 로 디코딩하여 바이트 배열로 변환
-> 시큐리티의 Key 객체로 HMAC 키 생성
-> secretKey, 알고리즘 적용
val keyBytes: ByteArray = Decoders.BASE64.decode(secretKey)
val key: Key = Keys.hmacShaKeyFor(keyBytes)
.signWith(key, io.jsonwebtoken.SignatureAlgorithm.HS256)
잘 따라했고 빗금도 없어졌는데 또 JWT 에러가 떴다.
구글링해보니 파싱할 때의 secreKey 와 생성할 때 secretKey 가 달라서 파싱할 때도 바이트로 변환해줘야 한다고한다.
그런데 난 이미 바이트 변환을 해주고 있었다.
혹시 모르니 바이트 변환을 빼주고 다시 실행했다.
됐다! 대신 이번엔 파싱부분에 빗금이 생겼다...
signWith() 와 마찬가지로
문자열로 된 secretKey 를 Base64 로 디코딩하여 바이트 배열로 변환
-> 시큐리티의 Key 객체로 HMAC 키 생성
한 key 를 인자로 넣으라고 한다.
위에서 선언해준 key 변수를 집어넣었더니 해결되었다.
네트워크 연결
2024-01-15T13:01:43.691+09:00 ERROR 11788 --- [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Exception during pool initialization.org.postgresql.util.PSQLException: This connection has been closed.
'왕초보일지' 카테고리의 다른 글
240116 TIL | (0) | 2024.01.16 |
---|---|
240113 TIL | (1) | 2024.01.13 |
240111 TIL | (3) | 2024.01.11 |
240110 TIL | 스프링시큐리티 로그인 구현하는 중 (1) | 2024.01.10 |
240109 TIL | (2) | 2024.01.09 |