diff --git a/build.gradle.kts b/build.gradle.kts index 1af5bad..aa3e827 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,16 @@ dependencies { testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.springframework.security:spring-security-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + //Validation 사용시 필요 + implementation("org.springframework.boot:spring-boot-starter-validation") + //Spring Security 사용시 필요 + implementation("org.springframework.boot:spring-boot-starter-security") + //JWT 사용시 필요 + implementation("io.jsonwebtoken:jjwt-api:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") + //암호화 알고리즘 사용시 필요 + implementation("org.bouncycastle:bcprov-jdk15on:1.70") } noArg{ diff --git a/src/main/kotlin/study/StudyApplication.kt b/src/main/kotlin/study/StudyApplication.kt index 59392af..805a11a 100644 --- a/src/main/kotlin/study/StudyApplication.kt +++ b/src/main/kotlin/study/StudyApplication.kt @@ -2,8 +2,10 @@ package study import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.data.jpa.repository.config.EnableJpaAuditing @SpringBootApplication +@EnableJpaAuditing class StudyApplication fun main(args: Array) { diff --git a/src/main/kotlin/study/common/annotation/ValidEnum.kt b/src/main/kotlin/study/common/annotation/ValidEnum.kt new file mode 100644 index 0000000..da9c8db --- /dev/null +++ b/src/main/kotlin/study/common/annotation/ValidEnum.kt @@ -0,0 +1,42 @@ +//annotation: 사용자 생성 어노테이션 +package study.common.annotation + +import jakarta.validation.Constraint +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext +import jakarta.validation.Payload +import kotlin.reflect.KClass + +//@Target: annotation 이 적용될 위치 선택 +//@Retention: 어노테이션을 컴파일된 클래스 파일에 저장할 것인지(SOURCE) 런타임에 반영할 것인지(RUNTIME) 정의 +//@MustBeDocumented: API 의 일부분으로 문서화하기 위해 사용 +//@Constraint +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +@Constraint(validatedBy = [ValidEnumValidator::class]) +//annotation: 주석처럼 코드에 달아 클래스에 특별한 의미 부여, 기능 주입 ex)@Override +annotation class ValidEnum ( + val message: String = "Invalid enum value", + val groups: Array> = [], + val payload: Array> = [], + val enumClass: KClass> +) + +//유효성 검사 +class ValidEnumValidator : ConstraintValidator { + private lateinit var enumValues: Array> + + override fun initialize(annotation: ValidEnum) { + enumValues = annotation.enumClass.java.enumConstants + } + + //value: 사용자로부터 받은 값 + override fun isValid(value: Any?, context: ConstraintValidatorContext): Boolean { + if (value == null) { + return true + } + //any: 조건을 만족하는 원소가 1개 이상 존재하면 true + return enumValues.any {it.name == value.toString()} + } +} \ No newline at end of file diff --git a/src/main/kotlin/study/common/authority/JwtAuthenticationFilter.kt b/src/main/kotlin/study/common/authority/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..d056700 --- /dev/null +++ b/src/main/kotlin/study/common/authority/JwtAuthenticationFilter.kt @@ -0,0 +1,38 @@ +//JwtAuthenticationFilter: 토큰 정보 검사, Security Context Holder 에 정보 기록 +package study.common.authority + +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse +import jakarta.servlet.http.HttpServletRequest +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.util.StringUtils +import org.springframework.web.filter.GenericFilterBean + +class JwtAuthenticationFilter ( + private val jwtTokenProvider: JwtTokenProvider +) : GenericFilterBean() {//GenericFilterBean 상속 + override fun doFilter(request: ServletRequest?, response: ServletResponse?, chain: FilterChain?) { + val token = resolveToken(request as HttpServletRequest)//access token 정보 + + //정상 토큰이면 정보 추출 -> SecurityContextHolder 에 기록 + if (token != null && jwtTokenProvider.validateToken(token)) { + val authentication = jwtTokenProvider.getAuthentication(token) + SecurityContextHolder.getContext().authentication = authentication + } + + chain?.doFilter(request, response) + } + + //request 로부터 Header Authorization 문자를 가져와 bearer 와 맞는지 찾음 + //bearer 와 맞으면 key 값만 추출 + private fun resolveToken(request: HttpServletRequest): String? { + val bearerToken = request.getHeader("Authorization") + + return if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) { + bearerToken.substring(7) + } else { + null + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/study/common/authority/JwtTokenProvider.kt b/src/main/kotlin/study/common/authority/JwtTokenProvider.kt new file mode 100644 index 0000000..aa31969 --- /dev/null +++ b/src/main/kotlin/study/common/authority/JwtTokenProvider.kt @@ -0,0 +1,101 @@ +package study.common.authority + +import io.jsonwebtoken.* +import io.jsonwebtoken.io.Decoders +import io.jsonwebtoken.security.Keys +import io.jsonwebtoken.security.SecurityException +import org.springframework.beans.factory.annotation.Value +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.userdetails.UserDetails +import org.springframework.stereotype.Component +import study.common.dto.CustomUser +import java.util.* + +const val EXPIRATION_MILLISECONDS: Long = 1000 * 60 * 30//60초 * 30개 -> 30분 +//JwtTokenProvider: 토큰 생성, 정보 추출, 검증 +@Component +class JwtTokenProvider { + @Value("\${jwt.secret}") + lateinit var secretKey: String + + private val key by lazy { Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey))} + + /** + * Token 생성 + */ + fun createToken(authentication: Authentication): TokenInfo { + val authorities: String = authentication + .authorities + .joinToString(",", transform = GrantedAuthority::getAuthority) + + //만료시간 설정 + val now = Date() + val accessExpiration = Date(now.time + EXPIRATION_MILLISECONDS) + + //Access Token 생성 + val accessToken = Jwts + .builder() + .subject(authentication.name) + .claim("auth", authorities)//auth 라는 이름으로 권한을 담음 + //토큰 생성시 userId 정보도 기록 + .claim("userId", (authentication.principal as CustomUser).userId) + .issuedAt(now)//토큰 발행 시간 + .expiration(accessExpiration)//토큰 유효 시간 + .signWith(key, Jwts.SIG.HS256)//사용한 알고리즘 + .compact() + + return TokenInfo("Bearer", accessToken) + } + + /** + * Token 정보 추출 + */ + fun getAuthentication(token: String): Authentication {//parameter: access token + val claims: Claims = getClaims(token) + + //auth 가 없으면 RuntimeException + val auth = claims["auth"] ?: throw RuntimeException("잘못된 토큰입니다.") + val userId = claims["userId"] ?: throw RuntimeException("잘못된 토큰입니다.") + + //권한 정보 추출 + val authorities: Collection = (auth as String) + .split(",") + .map {SimpleGrantedAuthority(it)} + + val principal: UserDetails = + CustomUser(userId.toString().toLong(),claims.subject, "", authorities) + + return UsernamePasswordAuthenticationToken(principal, "", authorities) + } + + /** + * Token 검증 + */ + fun validateToken(token: String): Boolean { + try {//문제가 없으면 true 반환 + getClaims(token) + return true + } catch (e: Exception) {//exception 별 처리 + when (e) { + is SecurityException -> {} //Invalid JWT Token + is MalformedJwtException -> {} //Invalid JWT Token + is ExpiredJwtException -> {} //Expired JWT Token + is UnsupportedJwtException -> {} //Unsupported JWT Token + is IllegalArgumentException -> {} //JWT claims string is empty + else -> {} + } + println(e.message) + } + return false + } + + private fun getClaims(token: String): Claims = + Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .payload +} \ No newline at end of file diff --git a/src/main/kotlin/study/common/authority/SecurityConfig.kt b/src/main/kotlin/study/common/authority/SecurityConfig.kt new file mode 100644 index 0000000..beacc7e --- /dev/null +++ b/src/main/kotlin/study/common/authority/SecurityConfig.kt @@ -0,0 +1,48 @@ +//SecurityConfig: 인증, 인가 관리 +package study.common.authority + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.factory.PasswordEncoderFactories +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +class SecurityConfig ( + private val jwtTokenProvider: JwtTokenProvider +) { + @Bean//스프링 컨테이너를 통해 관리되는 객체 + fun filterChain(http: HttpSecurity): SecurityFilterChain { + http + .httpBasic { it.disable() } + .csrf { it.disable() } + //JWT 를 사용하기 때문에 Session 사용 X + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + //권한 관리 + //인증되지 않은 사용자만 "/api/member/signup, login" URL 호출 가능 + //그 외의 요청은 회원 권한이 있어야 가능 + .authorizeHttpRequests { + it.requestMatchers("/api/member/signup", "/api/member/login").anonymous() + .requestMatchers("/api/member/info/**").hasRole("MEMBER") + .requestMatchers("api/post/posting").hasRole("MEMBER") + .anyRequest().permitAll() + } + //뒤 필터를 실행하기 전에 앞 필터를 먼저 실행, 앞 필터가 통과되면 뒤 필터는 실행 X + .addFilterBefore( + JwtAuthenticationFilter(jwtTokenProvider), + UsernamePasswordAuthenticationFilter::class.java + ) + + return http.build() + } + + //코드 암호화 + @Bean + fun passwordEncoder(): PasswordEncoder = + PasswordEncoderFactories.createDelegatingPasswordEncoder() +} \ No newline at end of file diff --git a/src/main/kotlin/study/common/authority/TokenInfo.kt b/src/main/kotlin/study/common/authority/TokenInfo.kt new file mode 100644 index 0000000..12ad1f6 --- /dev/null +++ b/src/main/kotlin/study/common/authority/TokenInfo.kt @@ -0,0 +1,8 @@ +//authority: 관한 관련 기능 분류 +package study.common.authority + +//TokenInfo: 로그인 시 토큰 정보를 담아 클라이언트에게 전달 +data class TokenInfo ( + val grantType: String,//JWT 권한 인증 타입 + val accessToken: String,//실제 검증할 토큰 +) \ No newline at end of file diff --git a/src/main/kotlin/study/common/dto/BaseResponse.kt b/src/main/kotlin/study/common/dto/BaseResponse.kt new file mode 100644 index 0000000..e2e7681 --- /dev/null +++ b/src/main/kotlin/study/common/dto/BaseResponse.kt @@ -0,0 +1,10 @@ +//dto: 어플리케이션 전반에 공통적으로 사용할 수 있는 DTO +package study.common.dto + +import study.common.status.ResultCode + +data class BaseResponse ( + val resultCode: String = ResultCode.SUCCESS.name,//결과 코드 + val data: T? = null,//조회, 처리시 데이터를 담아서 반환해줄 data + val message: String = ResultCode.SUCCESS.msg,//처리 메세지 +) \ No newline at end of file diff --git a/src/main/kotlin/study/common/dto/CustomUser.kt b/src/main/kotlin/study/common/dto/CustomUser.kt new file mode 100644 index 0000000..28f2feb --- /dev/null +++ b/src/main/kotlin/study/common/dto/CustomUser.kt @@ -0,0 +1,11 @@ +package study.common.dto + +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.userdetails.User + +class CustomUser ( + val userId: Long, + userName: String, + password: String, + authorities: Collection +) : User(userName, password, authorities) \ No newline at end of file diff --git a/src/main/kotlin/study/common/exception/CustomExceptionHandler.kt b/src/main/kotlin/study/common/exception/CustomExceptionHandler.kt new file mode 100644 index 0000000..1194258 --- /dev/null +++ b/src/main/kotlin/study/common/exception/CustomExceptionHandler.kt @@ -0,0 +1,50 @@ +//exception: 예외 처리 Handler +package study.common.exception + +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.validation.FieldError +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import study.common.dto.BaseResponse +import study.common.status.ResultCode + +@RestControllerAdvice +class CustomExceptionHandler { + + //@Valid(DTO의 validation)에서 발생한 exception + @ExceptionHandler(MethodArgumentNotValidException::class) + protected fun methodArgumentNotValidException(ex: MethodArgumentNotValidException) : ResponseEntity>> { + val errors = mutableMapOf() + ex.bindingResult.allErrors.forEach { error -> + val fieldName = (error as FieldError).field + val errorMessage = error.defaultMessage + errors[fieldName] = errorMessage ?: "Not Exception Message" + } + + return ResponseEntity(BaseResponse(ResultCode.ERROR.name, errors, ResultCode.ERROR.msg), HttpStatus.BAD_REQUEST) + } + + //사용자 생성 exception + @ExceptionHandler(InvalidInputException::class) + protected fun invalidInputException(ex: InvalidInputException) : ResponseEntity>> { + val errors = mapOf(ex.fieldName to (ex.message ?: "Not Exception Message")) + return ResponseEntity(BaseResponse(ResultCode.ERROR.name, errors, ResultCode.ERROR.msg), HttpStatus.BAD_REQUEST) + } + + //로그인 실패 exception + @ExceptionHandler(BadCredentialsException::class) + protected fun badCredentialsException(ex: BadCredentialsException) : ResponseEntity>> { + val errors = mapOf("로그인 실패" to "아이디 혹은 비밀번호를 다시 확인하세요.") + return ResponseEntity(BaseResponse(ResultCode.ERROR.name, errors, ResultCode.ERROR.msg), HttpStatus.BAD_REQUEST) + } + + //그 외의 처리하지 못 한 모든 exception + @ExceptionHandler(Exception::class) + protected fun defaultException(ex: InvalidInputException) : ResponseEntity>> { + val errors = mapOf("미처리 에러" to (ex.message ?: "Not Exception Message")) + return ResponseEntity(BaseResponse(ResultCode.ERROR.name, errors, ResultCode.ERROR.msg), HttpStatus.BAD_REQUEST) + } +} \ No newline at end of file diff --git a/src/main/kotlin/study/common/exception/InvalidInputException.kt b/src/main/kotlin/study/common/exception/InvalidInputException.kt new file mode 100644 index 0000000..e852886 --- /dev/null +++ b/src/main/kotlin/study/common/exception/InvalidInputException.kt @@ -0,0 +1,8 @@ +//exception: 예외 처리 +package study.common.exception + +//DB 확인 후 발생하는 예외 처리 +class InvalidInputException ( + val fieldName: String = "", + message: String = "Invalid Input" +) : RuntimeException(message)//RuntimeException 상속 \ No newline at end of file diff --git a/src/main/kotlin/study/common/service/CustomUserDetailsService.kt b/src/main/kotlin/study/common/service/CustomUserDetailsService.kt new file mode 100644 index 0000000..4c39c7b --- /dev/null +++ b/src/main/kotlin/study/common/service/CustomUserDetailsService.kt @@ -0,0 +1,31 @@ +package study.common.service + +import study.member.entity.Member +import study.member.repository.MemberRepository +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import study.common.dto.CustomUser + +@Service +class CustomUserDetailsService ( + private val memberRepository: MemberRepository, + private val passwordEncoder: PasswordEncoder, +) : UserDetailsService { + override fun loadUserByUsername(username: String): UserDetails = + memberRepository.findByLoginId(username)//고객ID로 찾기 + ?.let { createUserDetails(it) } //검색되는 정보가 있음 createUserDetails() 호출 + ?: throw UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다.") //검색되는 정보가 없음 + + private fun createUserDetails(member: Member): UserDetails = + CustomUser( + member.userId!!, + member.loginId, + passwordEncoder.encode(member.password), + member.memberRole!!.map { SimpleGrantedAuthority("ROLE_${it.role}")} + ) +} \ No newline at end of file diff --git a/src/main/kotlin/study/common/status/EnumStatus.kt b/src/main/kotlin/study/common/status/EnumStatus.kt new file mode 100644 index 0000000..fcc4587 --- /dev/null +++ b/src/main/kotlin/study/common/status/EnumStatus.kt @@ -0,0 +1,22 @@ +//어플리케이션에서 사용할 status +package study.common.status + +//기숙사 타입 +enum class Dormitory(val desc: String) { + GOA("고운A"), + GOB("고운B"), + GOC("고운C"), + KYUNG11("경상11"), + KYUNG12("경상12"), + KYUNG13("경상13"), + KYUNG14("경상14"), +} + +enum class ResultCode(val msg: String) { + SUCCESS("정상 처리 되었습니다."), + ERROR("에러가 발생했습니다.") +} + +enum class ROLE { + MEMBER +} \ No newline at end of file diff --git a/src/main/kotlin/study/member/controller/MemberController.kt b/src/main/kotlin/study/member/controller/MemberController.kt new file mode 100644 index 0000000..5bf4240 --- /dev/null +++ b/src/main/kotlin/study/member/controller/MemberController.kt @@ -0,0 +1,90 @@ +//controller: Request를 받을 EndPoint +package study.member.controller + +import jakarta.validation.Valid +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import study.common.authority.TokenInfo +import study.common.dto.BaseResponse +import study.common.dto.CustomUser +import study.member.dto.LoginDto +import study.member.dto.MemberDtoRequest +import study.member.dto.MemberDtoResponse +import study.member.service.MemberService + +//EndPoint: POST /api/member/signup +@RequestMapping("/api/member") +@RestController +class MemberController ( + private val memberService: MemberService +){ + /** + * 회원가입 + */ + @PostMapping("/signup") + //@Valid 추가: validation 체크 + //Unit: void + fun signUp(@RequestBody @Valid memberDtoRequest: MemberDtoRequest): BaseResponse { + val resultMsg: String = memberService.signUp(memberDtoRequest) + return BaseResponse(message = resultMsg) + } + + /** + * 로그인 + */ + @PostMapping("/login") + fun login(@RequestBody @Valid loginDto: LoginDto): BaseResponse { + val tokenInfo = memberService.login(loginDto) + return BaseResponse(data = tokenInfo) + } + + /** + * 내 정보 조회 + */ + @GetMapping("/info") + fun searchMyInfo(): BaseResponse { + val userId = (SecurityContextHolder + .getContext() + .authentication + .principal as CustomUser) + .userId + val response = memberService.searchMyInfo(userId) + return BaseResponse(data = response) + } + + /** + * 내 정보 수정 + */ + @PutMapping("/info") + fun changeMyInfo(@RequestBody @Valid memberDtoRequest: MemberDtoRequest): + BaseResponse { + val userId = (SecurityContextHolder + .getContext() + .authentication + .principal as CustomUser) + .userId + memberDtoRequest.userId = userId + val resultMsg: String = memberService.changeMyInfo(memberDtoRequest) + return BaseResponse(message = resultMsg) + } + + /** + * 같은 기숙사 조회 + */ + @GetMapping("/dorm/info") + fun getDormInfo(): BaseResponse> { + val userId = (SecurityContextHolder + .getContext() + .authentication + .principal as CustomUser) + .userId + val result = memberService.getDormInfo(userId) + return BaseResponse(data = result) + } +} \ No newline at end of file diff --git a/src/main/kotlin/study/member/dto/MemberDtos.kt b/src/main/kotlin/study/member/dto/MemberDtos.kt new file mode 100644 index 0000000..0c73b09 --- /dev/null +++ b/src/main/kotlin/study/member/dto/MemberDtos.kt @@ -0,0 +1,85 @@ +//dto: 회원 정보 관련 DTO +package study.member.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern +import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder +import study.common.annotation.ValidEnum +import study.common.status.Dormitory +import study.member.entity.Member + +//회원가입시 입력받을 정보 +data class MemberDtoRequest ( + var userId: Long?, + + @field:NotBlank//빈 값을 받지 않음 + @JsonProperty("loginId")//loginId와 _loginId 연결, loginId 사용 + private val _loginId: String?, + + @field:NotBlank + @field:Pattern( + //정규 표현식: 특정한 규칙의 문자열 집합을 표현 + //-> 불특정 문자열이 특정 조건에 만족하는지 판별할 때 사용 + regexp = "^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#\$%^&*])[a-zA-Z0-9!@#\$%^&*]{8,20}\$", + message = "영문, 숫자, 특수문자를 포함한 8~20자리로 입력해주세요" + ) + @JsonProperty("password") + private val _password: String?, + + @field:NotBlank + @JsonProperty("name") + private val _name: String?, + + @field:NotBlank + @field:Email + @JsonProperty("email") + private val _email: String?, + + @field:NotBlank + @field:ValidEnum(enumClass = Dormitory::class, message = "올바른 기숙사 타입을 선택해주세요.") + @JsonProperty("dormType") + private val _dormType: String?, +) {//Custom Getter + //암호화 기능 추가 + private val encoder = SCryptPasswordEncoder(16,8,1,8,8) + + val loginId: String + get() = _loginId!! + private val password: String + get() = encoder.encode(_password) + val name: String + get() = _name!! + val email: String + get() = _email!! + val dormType: Dormitory//String?을 enum class 로 변환 + get() = Dormitory.valueOf(_dormType!!) + + //Entity 반환 + fun toEntity(): Member = + Member(userId, loginId, password, name, email, dormType) +} + +data class LoginDto ( + @field:NotBlank//빈 값을 받지 않음, 필수값 + @JsonProperty("loginId") + private val _loginId: String?, + + @field:NotBlank + @JsonProperty("password") + private val _password: String?, +) {//custom getter + val loginId: String + get() = _loginId!! + val password: String + get() = _password!! +} + +data class MemberDtoResponse ( + val userId: Long, + val loginId: String, + val name: String, + val email: String, + val dormType: String, +) \ No newline at end of file diff --git a/src/main/kotlin/study/member/entity/MemberEntities.kt b/src/main/kotlin/study/member/entity/MemberEntities.kt new file mode 100644 index 0000000..51b7128 --- /dev/null +++ b/src/main/kotlin/study/member/entity/MemberEntities.kt @@ -0,0 +1,63 @@ +//entitiy: 회원 정보 관련 Entity +package study.member.entity + +import jakarta.persistence.* +import study.common.status.Dormitory +import study.common.status.ROLE +import study.member.dto.MemberDtoResponse + +@Entity +@Table( + //loginId 중복X + uniqueConstraints = [UniqueConstraint(name = "uk_member_login_id", columnNames = ["loginId"])] +) +class Member( + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + var userId: Long? = null, + + @Column(nullable = false, length = 30, updatable = false) + //updatable = false -> 업데이트시 loginId는 제외, 변경X + val loginId: String, + + @Column(nullable = false, length = 100) + val password: String, + + @Column(nullable = false, length = 10) + val name: String, + + @Column(nullable = false, length = 30) + val email: String, + + @Column(nullable = false, length = 10) + @Enumerated(EnumType.STRING)//db에 Dormitory의 이름(STRING)을 그대로 입력 + val dormType: Dormitory, +) {//1 : N 연결 + @OneToMany(fetch = FetchType.LAZY, mappedBy = "member") + val memberRole: List? = null + + //DTO 변경 함수 -> 비밀번호 제외 + fun toDto(): MemberDtoResponse = + MemberDtoResponse( + userId!!, + loginId, + name, + email, + dormType.desc //dormitory 에 해당하는 값을 받음 + ) +} + +@Entity +class MemberRole( + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + var userId: Long? = null, + + @Column(nullable = false, length = 30) + @Enumerated(EnumType.STRING) + val role: ROLE, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(foreignKey = ForeignKey(name = "fk_user_role_member_id")) + val member: Member, +) \ No newline at end of file diff --git a/src/main/kotlin/study/member/repository/MemberRepositories.kt b/src/main/kotlin/study/member/repository/MemberRepositories.kt new file mode 100644 index 0000000..3dbe6ef --- /dev/null +++ b/src/main/kotlin/study/member/repository/MemberRepositories.kt @@ -0,0 +1,16 @@ +//repository: 회원 정보 관련 Repository +package study.member.repository + +import org.springframework.data.jpa.repository.JpaRepository +import study.common.status.Dormitory +import study.member.entity.Member +import study.member.entity.MemberRole + +interface MemberRepository : JpaRepository {//JpaRepository 상속 + //loginId로 찾기, ID 중복 검사를 위해 필요 + fun findByLoginId(loginId: String): Member? + //같은 기숙사 타입 찾기 + fun findByDormType(dormType: Dormitory): MutableList +} + +interface MemberRoleRepository : JpaRepository \ No newline at end of file diff --git a/src/main/kotlin/study/member/service/MemberService.kt b/src/main/kotlin/study/member/service/MemberService.kt new file mode 100644 index 0000000..da44bd9 --- /dev/null +++ b/src/main/kotlin/study/member/service/MemberService.kt @@ -0,0 +1,102 @@ +//비즈니스 로직 +package study.member.service + +import jakarta.transaction.Transactional +import org.springframework.data.repository.findByIdOrNull +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder +import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder +import org.springframework.stereotype.Service +import study.common.authority.JwtTokenProvider +import study.common.authority.TokenInfo +import study.common.exception.InvalidInputException +import study.common.status.Dormitory +import study.common.status.ROLE +import study.member.dto.LoginDto +import study.member.dto.MemberDtoRequest +import study.member.dto.MemberDtoResponse +import study.member.entity.Member +import study.member.entity.MemberRole +import study.member.repository.MemberRepository +import study.member.repository.MemberRoleRepository + +@Transactional +@Service +class MemberService( + private val memberRepository: MemberRepository, + private val memberRoleRepository: MemberRoleRepository, + private val authenticationManagerBuilder: AuthenticationManagerBuilder, + private val jwtTokenProvider: JwtTokenProvider, +) { + /** + * 회원가입 + */ + //MemberDtoRequest class: 회원가입시 입력받을 정보 + fun signUp(memberDtoRequest: MemberDtoRequest): String { + //ID 중복 검사 -> ID 조회가 가능하면 member != null + var member: Member? = memberRepository.findByLoginId(memberDtoRequest.loginId) + if (member != null) { + throw InvalidInputException("loginId", "이미 등록된 ID 입니다.") + } + + //사용자 정보 저장 + member = memberDtoRequest.toEntity() + memberRepository.save(member) //insert + + //권한 저장 + val memberRole = MemberRole(null, ROLE.MEMBER, member) + memberRoleRepository.save(memberRole) + + return "회원가입이 완료되었습니다." + } + + /** + * 로그인 -> 토큰 발행 + */ + fun login(loginDto: LoginDto): TokenInfo { + val member = memberRepository.findByLoginId(loginDto.loginId) + ?: throw InvalidInputException("로그인 아이디 혹은 비밀번호가 틀렸습니다.") + val encoder = SCryptPasswordEncoder(16,8,1,8,8) + if(!encoder.matches(loginDto.password, member.password)) { + throw InvalidInputException("로그인 아이디 혹은 비밀번호가 틀렸습니다.") + } + + //loginDto 의 password 암호화 -> DB에 존재하는 내용으로 토큰 발행 + val authenticationToken = + UsernamePasswordAuthenticationToken(loginDto.loginId, member.password) + val authentication = + authenticationManagerBuilder.`object`.authenticate(authenticationToken) + //DB에 있는 유저네임과 비교, 문제가 없으면 사용자에게 토큰 발행 + + return jwtTokenProvider.createToken(authentication) + } + + /** + * 내 정보 조회 + */ + fun searchMyInfo(userId: Long): MemberDtoResponse { + //해당하는 id가 없으면 예외 처리 + val member = memberRepository.findByIdOrNull(userId) + ?: throw InvalidInputException("userId", "회원번호(${userId}): 존재하지 않는 사용자입니다.") + return member.toDto() + } + + /** + * 내 정보 수정 + */ + fun changeMyInfo(memberDtoRequest: MemberDtoRequest): String { + val member = memberDtoRequest.toEntity() + memberRepository.save(member) + return "정보 수정이 완료되었습니다." + } + + /** + * 같은 기숙사 조회 -> userId로 기준 기숙사 타입 결정 + * !!으로 null 이 아닌 경우에만 기숙사 타입 받음 + */ + fun getDormInfo(userId: Long): List { + val dormType = memberRepository.findByIdOrNull(userId)!!.dormType + val result = memberRepository.findByDormType(dormType) + return result.map { it.toDto() } + } +} \ No newline at end of file diff --git a/src/main/kotlin/study/post/controller/PostController.kt b/src/main/kotlin/study/post/controller/PostController.kt new file mode 100644 index 0000000..594683a --- /dev/null +++ b/src/main/kotlin/study/post/controller/PostController.kt @@ -0,0 +1,50 @@ +package study.post.controller + +import jakarta.validation.Valid +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.annotation.* +import study.common.dto.BaseResponse +import study.common.dto.CustomUser +import study.post.dto.PostDtoRequest +import study.post.entity.Post +import study.post.service.PostService + +@RequestMapping("/api/post") +@RestController +class PostController( + private val postService: PostService +) { + /** + * 게시글 작성 + */ + @PostMapping("/posting") + fun posting(@RequestBody @Valid postDtoRequest: PostDtoRequest): BaseResponse { + val userId = (SecurityContextHolder + .getContext() + .authentication + .principal as CustomUser)//CustomUser 형식으로 userId 받음 + .userId + + val result = postService.posting(postDtoRequest, userId) + return BaseResponse(result) + } + + /** + * 전체 게시글 조회 + */ + @GetMapping("/") + fun getAllPosts() : BaseResponse> { + val list = postService.getAllPosts() + return BaseResponse(data = list) + } + + /** + * 특정 게시글 조회 + */ + @GetMapping("/{postId}") + //@PathVariable: 클라이언트 측에서 url 에 인자를 전달하는 경우에 사용, url 경로에 변수를 넣어줌 + fun getPost(@PathVariable postId: Long) : BaseResponse { + val result = postService.getPost(postId) + return BaseResponse(data = result) + } +} \ No newline at end of file diff --git a/src/main/kotlin/study/post/dto/PostDtos.kt b/src/main/kotlin/study/post/dto/PostDtos.kt new file mode 100644 index 0000000..ffd06d6 --- /dev/null +++ b/src/main/kotlin/study/post/dto/PostDtos.kt @@ -0,0 +1,35 @@ +package study.post.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.constraints.NotBlank +import study.post.entity.Post +import java.time.LocalDateTime + +data class PostDtoRequest( + val userId: Long? = null, + + @field:NotBlank //빈칸 허용 x + @JsonProperty("title") + private val _title : String?, + + @field:NotBlank + @JsonProperty("content") + private val _content : String?, + + //게시글 좋아요 수 + private val likes : Long = 0, + + private val createDate: LocalDateTime = LocalDateTime.now() +) { + val title: String + get() = _title!!.toString() + val content: String + get() = _content!!.toString() + + //Member 의 name 을 writer 로 사용 + fun toEntity(writer: String): Post { + return Post( + null, title, content, writer, likes, createDate + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/study/post/entity/PostEntities.kt b/src/main/kotlin/study/post/entity/PostEntities.kt new file mode 100644 index 0000000..00ad768 --- /dev/null +++ b/src/main/kotlin/study/post/entity/PostEntities.kt @@ -0,0 +1,27 @@ +package study.post.entity + +import jakarta.persistence.* +import java.time.LocalDateTime + +@Entity +class Post( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val userId: Long? = null, + + //title, content, like 수정 가능, writer, date 는 수정 불가능 + @Column(nullable = false, length = 30, updatable = true) + var title: String, + + @Column(nullable = false, length = 500, updatable = true) + var content: String, + + @Column(nullable = false, length = 10, updatable = false) + val writer : String, + + @Column(nullable = false, updatable = true) + var likes : Long, + + @Column(nullable = false, updatable = false) + val createDate: LocalDateTime +) \ No newline at end of file diff --git a/src/main/kotlin/study/post/repository/PostRepositories.kt b/src/main/kotlin/study/post/repository/PostRepositories.kt new file mode 100644 index 0000000..35d7b1a --- /dev/null +++ b/src/main/kotlin/study/post/repository/PostRepositories.kt @@ -0,0 +1,8 @@ +package study.post.repository + +import org.springframework.data.jpa.repository.JpaRepository +import study.post.entity.Post + +interface PostRepository: JpaRepository { + fun findPostByUserId(postId: Long): Post? +} \ No newline at end of file diff --git a/src/main/kotlin/study/post/service/PostSevice.kt b/src/main/kotlin/study/post/service/PostSevice.kt new file mode 100644 index 0000000..b51ac12 --- /dev/null +++ b/src/main/kotlin/study/post/service/PostSevice.kt @@ -0,0 +1,47 @@ +package study.post.service + +import jakarta.transaction.Transactional +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import study.common.exception.InvalidInputException +import study.member.repository.MemberRepository +import study.post.dto.PostDtoRequest +import study.post.entity.Post +import study.post.repository.PostRepository + +@Transactional +@Service +class PostService( + private val memberRepository: MemberRepository, + private val postRepository: PostRepository, +) { + /** + * 게시글 작성 -> 회원가입한 사용자만 작성 가능 + */ + fun posting(postDtoRequest: PostDtoRequest, userId: Long): String { + //관련 없는 토큰이 들어왔을 때, 토큰이 안 들어오면 에러 + val member = memberRepository.findByIdOrNull(userId) + ?: throw InvalidInputException("id", "회원번호(${userId}): 존재하지 않는 사용자입니다.") + + //게시글 작성자 writer == member.name + val post = postDtoRequest.toEntity(member.name) + postRepository.save(post) + return "게시글 작성이 완료되었습니다." + } + + /** + * 전체 게시글 조회 + */ + fun getAllPosts(): MutableList { + return postRepository.findAll() + } + + /** + * 특정 게시글 조회 + */ + fun getPost(postId: Long): Post { + val post = postRepository.findPostByUserId(postId) + ?: throw InvalidInputException("게시글번호(${postId}): 존재하지 않는 게시글입니다.") + return post + } +} \ No newline at end of file