시큐리티 설정

유저 API - 로그인, 회원가입, 계정정보 추가
This commit is contained in:
Klaus 2023-07-23 03:26:17 +09:00
parent 23506e79f1
commit f81f07bd05
36 changed files with 1247 additions and 0 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
HELP.md
.gradle
.envrc
build/
!**/src/main/**/build/
!**/src/test/**/build/

View File

@ -33,11 +33,21 @@ dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
// jwt
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
// querydsl (추가 설정)
implementation("com.querydsl:querydsl-jpa:$querydslVersion")
kapt("com.querydsl:querydsl-apt:$querydslVersion:jpa")
kapt("org.springframework.boot:spring-boot-configuration-processor")
// aws
implementation("com.amazonaws:aws-java-sdk-ses:1.12.380")
implementation("com.amazonaws:aws-java-sdk-s3:1.12.380")
implementation("com.amazonaws:aws-java-sdk-cloudfront:1.12.380")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
@ -45,6 +55,12 @@ dependencies {
testImplementation("org.springframework.security:spring-security-test")
}
allOpen {
annotation("javax.persistence.Entity")
annotation("javax.persistence.MappedSuperclass")
annotation("javax.persistence.Embeddable")
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"

View File

@ -2,8 +2,10 @@ package kr.co.vividnext.sodalive
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableAsync
@SpringBootApplication
@EnableAsync
class SodaLiveApplication
fun main(args: Array<String>) {

View File

@ -0,0 +1,34 @@
package kr.co.vividnext.sodalive.aws.s3
import com.amazonaws.services.s3.AmazonS3Client
import com.amazonaws.services.s3.model.ObjectMetadata
import com.amazonaws.services.s3.model.PutObjectRequest
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.io.InputStream
@Component
class S3Uploader(private val amazonS3Client: AmazonS3Client) {
private val logger = LoggerFactory.getLogger(this::class.java)
fun upload(
inputStream: InputStream,
bucket: String,
filePath: String,
metadata: ObjectMetadata? = null
): String {
putS3(inputStream, bucket, filePath, metadata)
return filePath
}
private fun putS3(
inputStream: InputStream,
bucket: String,
filePath: String,
metadata: ObjectMetadata?
): String {
amazonS3Client.putObject(PutObjectRequest(bucket, filePath, inputStream, metadata))
logger.info("파일이 업로드 되었습니다.")
return amazonS3Client.getUrl(bucket, filePath).toString()
}
}

View File

@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.common
data class ApiResponse<T>(
val success: Boolean,
val message: String? = null,
val data: T? = null,
val errorProperty: String? = null
) {
companion object {
fun <T> ok(data: T? = null, message: String? = null) = ApiResponse(
success = true,
message = message,
data = data
)
fun error(message: String? = null, errorProperty: String? = null) = ApiResponse<Any>(
success = false,
message = message,
errorProperty = errorProperty
)
}
}

View File

@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.common
import java.time.LocalDateTime
import javax.persistence.GeneratedValue
import javax.persistence.GenerationType
import javax.persistence.Id
import javax.persistence.MappedSuperclass
import javax.persistence.PrePersist
import javax.persistence.PreUpdate
@MappedSuperclass
abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
var createdAt: LocalDateTime? = null
var updatedAt: LocalDateTime? = null
@PrePersist
fun prePersist() {
createdAt = LocalDateTime.now()
updatedAt = LocalDateTime.now()
}
@PreUpdate
fun preUpdate() {
updatedAt = LocalDateTime.now()
}
}

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.common
class SodaException(message: String, val errorProperty: String? = null) : RuntimeException(message)

View File

@ -0,0 +1,60 @@
package kr.co.vividnext.sodalive.common
import org.slf4j.LoggerFactory
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.authentication.InternalAuthenticationServiceException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.multipart.MaxUploadSizeExceededException
@RestControllerAdvice
class SodaExceptionHandler {
private val logger = LoggerFactory.getLogger(this::class.java)
@ExceptionHandler(SodaException::class)
fun handleSudaException(e: SodaException) = run {
logger.error("API error", e)
ApiResponse.error(
message = e.message,
errorProperty = e.errorProperty
)
}
@ExceptionHandler(MaxUploadSizeExceededException::class)
fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run {
logger.error("API error", e)
ApiResponse.error(message = "파일용량은 최대 1024MB까지 저장할 수 있습니다.")
}
@ExceptionHandler(AccessDeniedException::class)
fun handleAccessDeniedException(e: AccessDeniedException) = run {
logger.error("API error", e)
ApiResponse.error(message = "권한이 없습니다.")
}
@ExceptionHandler(InternalAuthenticationServiceException::class)
fun handleInternalAuthenticationServiceException(e: InternalAuthenticationServiceException) = run {
logger.error("API error", e)
ApiResponse.error("로그인 정보를 확인해주세요.")
}
@ExceptionHandler(BadCredentialsException::class)
fun handleBadCredentialsException(e: BadCredentialsException) = run {
logger.error("API error", e)
ApiResponse.error("로그인 정보를 확인해주세요.")
}
@ExceptionHandler(DataIntegrityViolationException::class)
fun handleDataIntegrityViolationException(e: DataIntegrityViolationException) = run {
logger.error("API error", e)
ApiResponse.error("이미 등록되어 있습니다.")
}
@ExceptionHandler(Exception::class)
fun handleException(e: Exception) = run {
logger.error("API error", e)
ApiResponse.error("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
}

View File

@ -0,0 +1,29 @@
package kr.co.vividnext.sodalive.configs
import com.amazonaws.auth.AWSStaticCredentialsProvider
import com.amazonaws.auth.BasicAWSCredentials
import com.amazonaws.services.s3.AmazonS3Client
import com.amazonaws.services.s3.AmazonS3ClientBuilder
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class AmazonS3Config(
@Value("\${cloud.aws.credentials.access-key}")
private val accessKey: String,
@Value("\${cloud.aws.credentials.secret-key}")
private val secretKey: String,
@Value("\${cloud.aws.region.static}")
private val region: String
) {
@Bean
fun amazonS3Client(): AmazonS3Client {
val awsCredentials = BasicAWSCredentials(accessKey, secretKey)
return AmazonS3ClientBuilder.standard()
.withRegion(region)
.withCredentials(AWSStaticCredentialsProvider(awsCredentials))
.build() as AmazonS3Client
}
}

View File

@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.configs
import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import javax.persistence.EntityManager
import javax.persistence.PersistenceContext
@Configuration
class QueryDslConfig(
@PersistenceContext
private val entityManager: EntityManager
) {
@Bean
fun jpaQueryFactory(): JPAQueryFactory {
return JPAQueryFactory(entityManager)
}
}

View File

@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.configs
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories
@Configuration
@EnableRedisRepositories
class RedisConfig(
@Value("\${spring.redis.host}")
private val host: String,
@Value("\${spring.redis.port}")
private val port: Int
) {
@Bean
fun redisConnectionFactory(): RedisConnectionFactory {
return LettuceConnectionFactory(host, port)
}
@Bean
fun redisTemplate(): RedisTemplate<*, *> {
val redisTemplate: RedisTemplate<*, *> = RedisTemplate<Any, Any>()
redisTemplate.setConnectionFactory(redisConnectionFactory())
return redisTemplate
}
}

View File

@ -0,0 +1,74 @@
package kr.co.vividnext.sodalive.configs
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
import kr.co.vividnext.sodalive.jwt.JwtFilter
import kr.co.vividnext.sodalive.jwt.TokenProvider
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.builders.WebSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfig(
private val tokenProvider: TokenProvider,
private val accessDeniedHandler: JwtAccessDeniedHandler,
private val authenticationEntryPoint: JwtAuthenticationEntryPoint
) {
@Bean
fun passwordEncoder(): PasswordEncoder {
return BCryptPasswordEncoder()
}
@Bean
fun webSecurityCustomizer(): WebSecurityCustomizer {
return WebSecurityCustomizer { web: WebSecurity ->
web
.ignoring()
.antMatchers("/h2-console/**", "/favicon.ico", "/error")
}
}
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
val jwtFilter = JwtFilter(tokenProvider)
return http
.cors()
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
.and()
.headers()
.frameOptions()
.sameOrigin()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/member/check/email").permitAll()
.antMatchers("/member/check/nickname").permitAll()
.antMatchers("/member/signup").permitAll()
.antMatchers("/member/login").permitAll()
.antMatchers("/member/forgot-password").permitAll()
.antMatchers("/stplat/terms_of_service").permitAll()
.antMatchers("/stplat/privacy_policy").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java)
.build()
}
}

View File

@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.configs
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class WebConfig : WebMvcConfigurer {
override fun addCorsMappings(registry: CorsRegistry) {
registry.addMapping("/**")
.allowedOrigins(
"http://localhost:8888",
"https://test-admin.sodalive.net",
"https://admin.sodalive.net"
)
.allowedMethods("*")
.allowCredentials(true)
}
}

View File

@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.jwt
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.stereotype.Component
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
@Component
class JwtAccessDeniedHandler : AccessDeniedHandler {
override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
accessDeniedException: AccessDeniedException
) {
response.sendError(HttpServletResponse.SC_FORBIDDEN)
}
}

View File

@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.jwt
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.stereotype.Component
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
@Component
class JwtAuthenticationEntryPoint : AuthenticationEntryPoint {
override fun commence(
request: HttpServletRequest,
response: HttpServletResponse,
authException: AuthenticationException
) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED)
}
}

View File

@ -0,0 +1,45 @@
package kr.co.vividnext.sodalive.jwt
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.util.StringUtils
import org.springframework.web.filter.OncePerRequestFilter
import javax.servlet.FilterChain
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class JwtFilter(private val tokenProvider: TokenProvider) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val jwt = resolveToken(request)
val requestURI = request.requestURI
if (jwt != null && StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
val authentication = tokenProvider.getAuthentication(jwt)
SecurityContextHolder.getContext().authentication = authentication
logger.debug("Security Context에 '${authentication.name}' 인증정보를 저장했습니다, uri: $requestURI")
} else {
logger.debug("유효한 JWT 토큰이 없습니다., uri: $requestURI")
if (response.status != 200) {
response.status = 401
}
}
filterChain.doFilter(request, response)
}
private fun resolveToken(request: HttpServletRequest): String? {
val bearerToken = request.getHeader(AUTHORIZATION_HEADER)
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7)
}
return null
}
companion object {
const val AUTHORIZATION_HEADER = "Authorization"
}
}

View File

@ -0,0 +1,133 @@
package kr.co.vividnext.sodalive.jwt
import io.jsonwebtoken.ExpiredJwtException
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.MalformedJwtException
import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.UnsupportedJwtException
import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys
import io.jsonwebtoken.security.SignatureException
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.token.MemberToken
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.InitializingBean
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull
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.stereotype.Component
import java.security.Key
import java.util.Date
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.write
// 토큰의 생성, 유효성 검증 담당 클래스
@Component
class TokenProvider(
@Value("\${jwt.secret}")
private val secret: String,
@Value("\${jwt.token-validity-in-seconds}")
private val tokenValidityInSeconds: Long,
private val repository: MemberRepository,
private val tokenRepository: MemberTokenRepository
) : InitializingBean {
private val logger = LoggerFactory.getLogger(TokenProvider::class.java)
private val tokenValidityInMilliseconds: Long = tokenValidityInSeconds * 1000
private val tokenLocks: MutableMap<Long, ReentrantReadWriteLock> = mutableMapOf()
private lateinit var key: Key
override fun afterPropertiesSet() {
val keyBytes = Decoders.BASE64.decode(secret)
this.key = Keys.hmacShaKeyFor(keyBytes)
}
fun createToken(authentication: Authentication, memberId: Long): String {
val authorities = authentication.authorities
.joinToString(separator = ",", transform = GrantedAuthority::getAuthority)
val now = Date().time
val validity = Date(now + tokenValidityInMilliseconds)
val token = Jwts.builder()
.setSubject(memberId.toString())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact()
val lock = getOrCreateLock(memberId = memberId)
lock.write {
val memberToken = tokenRepository.findByIdOrNull(memberId)
?: MemberToken(id = memberId, listOf())
val memberTokenSet = memberToken.tokenList.toMutableSet()
memberTokenSet.add(token)
memberToken.tokenList = memberTokenSet.toList()
tokenRepository.save(memberToken)
}
return token
}
fun getAuthentication(token: String): Authentication {
val claims = Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.body
val authorities = claims[AUTHORITIES_KEY].toString().split(",").map { SimpleGrantedAuthority(it) }
val memberToken = tokenRepository.findByIdOrNull(id = claims.subject.toLong())
?: throw SodaException("로그인 정보를 확인해주세요.")
if (!memberToken.tokenList.contains(token)) throw SodaException("로그인 정보를 확인해주세요.")
val member = repository.findByIdOrNull(id = claims.subject.toLong())
?: throw SodaException("로그인 정보를 확인해주세요.")
val principal = MemberAdapter(member)
return UsernamePasswordAuthenticationToken(principal, token, authorities)
}
fun validateToken(token: String): Boolean {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
return true
} catch (e: SecurityException) {
logger.info("잘못된 JWT 서명입니다.")
} catch (e: MalformedJwtException) {
logger.info("잘못된 JWT 서명입니다.")
} catch (e: ExpiredJwtException) {
logger.info("만료된 JWT 서명입니다.")
} catch (e: UnsupportedJwtException) {
logger.info("지원되지 않는 JWT 서명입니다.")
} catch (e: IllegalArgumentException) {
logger.info("JWT 토큰이 잘못되었습니다.")
} catch (e: SignatureException) {
logger.info("잘못된 JWT 서명입니다.")
}
return false
}
private fun getOrCreateLock(memberId: Long): ReentrantReadWriteLock {
return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() }
}
companion object {
private const val AUTHORITIES_KEY = "auth"
}
}

View File

@ -0,0 +1,104 @@
package kr.co.vividnext.sodalive.member
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.notification.MemberNotification
import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree
import javax.persistence.CascadeType
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.OneToMany
import javax.persistence.OneToOne
@Entity
data class Member(
val email: String,
var password: String,
var nickname: String,
var profileImage: String? = null,
@Enumerated(value = EnumType.STRING)
var gender: Gender = Gender.NONE,
@Enumerated(value = EnumType.STRING)
var role: MemberRole = MemberRole.USER,
var isActive: Boolean = true,
var container: String = "web"
) : BaseEntity() {
@OneToMany(mappedBy = "member", cascade = [CascadeType.ALL])
val stipulationAgrees: MutableList<StipulationAgree> = mutableListOf()
@OneToOne(mappedBy = "member", fetch = FetchType.LAZY)
var notification: MemberNotification? = null
@OneToOne(mappedBy = "member", fetch = FetchType.LAZY)
var auth: Auth? = null
// 소개
@Column(columnDefinition = "TEXT")
var introduce = ""
// SNS
var instagramUrl = ""
var youtubeUrl = ""
var websiteUrl = ""
var blogUrl = ""
var pushToken: String? = null
// 화폐
private var pgChargeCan: Int = 0
private var pgRewardCan: Int = 0
private var googleChargeCan: Int = 0
private var googleRewardCan: Int = 0
private var appleChargeCan: Int = 0
private var appleRewardCan: Int = 0
fun getChargeCan(container: String): Int {
return when (container) {
"ios" -> appleChargeCan + pgChargeCan
"aos" -> googleChargeCan + pgChargeCan
else -> pgChargeCan
}
}
fun getRewardCan(container: String): Int {
return when (container) {
"ios" -> appleRewardCan + pgRewardCan
"aos" -> googleRewardCan + pgRewardCan
else -> pgRewardCan
}
}
fun charge(chargeCan: Int, rewardCan: Int, container: String) {
when (container) {
"ios" -> {
appleChargeCan = chargeCan
appleRewardCan = rewardCan
}
"aos" -> {
googleChargeCan = chargeCan
googleRewardCan = rewardCan
}
else -> {
pgChargeCan = chargeCan
pgRewardCan = rewardCan
}
}
}
}
enum class Gender {
MALE, FEMALE, NONE
}
enum class MemberRole {
ADMIN, BOT, USER, CREATOR, AGENT
}

View File

@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.member
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.User
class MemberAdapter(val member: Member) : User(
member.email,
member.password,
listOf(SimpleGrantedAuthority("ROLE_${member.role.name}"))
)

View File

@ -0,0 +1,37 @@
package kr.co.vividnext.sodalive.member
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.login.LoginRequest
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/member")
class MemberController(private val service: MemberService) {
@PostMapping("/signup")
fun signUp(
@RequestPart("profileImage", required = false) profileImage: MultipartFile? = null,
@RequestPart("request") requestString: String
) = service.signUp(profileImage, requestString)
@PostMapping("/login")
fun login(@RequestBody loginRequest: LoginRequest) = service.login(loginRequest)
@GetMapping("/info")
fun getMemberInfo(
@RequestParam container: String?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok(service.getMemberInfo(member, container ?: "web"))
}
}

View File

@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.member
import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface MemberRepository : JpaRepository<Member, Long>, MemberQueryRepository {
fun findByEmail(email: String): Member?
fun findByNickname(nickname: String): Member?
}
interface MemberQueryRepository
@Repository
class MemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : MemberQueryRepository

View File

@ -0,0 +1,215 @@
package kr.co.vividnext.sodalive.member
import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.info.GetMemberInfoResponse
import kr.co.vividnext.sodalive.member.login.LoginRequest
import kr.co.vividnext.sodalive.member.login.LoginResponse
import kr.co.vividnext.sodalive.member.signUp.SignUpRequest
import kr.co.vividnext.sodalive.member.stipulation.Stipulation
import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree
import kr.co.vividnext.sodalive.member.stipulation.StipulationAgreeRepository
import kr.co.vividnext.sodalive.member.stipulation.StipulationIds
import kr.co.vividnext.sodalive.member.stipulation.StipulationRepository
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
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.core.context.SecurityContextHolder
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 org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
@Transactional(readOnly = true)
class MemberService(
private val repository: MemberRepository,
private val stipulationRepository: StipulationRepository,
private val stipulationAgreeRepository: StipulationAgreeRepository,
private val s3Uploader: S3Uploader,
private val validator: SignUpValidator,
private val tokenProvider: TokenProvider,
private val passwordEncoder: PasswordEncoder,
private val authenticationManagerBuilder: AuthenticationManagerBuilder,
private val objectMapper: ObjectMapper,
@Value("\${cloud.aws.s3.bucket}")
private val s3Bucket: String
) : UserDetailsService {
@Transactional
fun signUp(
profileImage: MultipartFile?,
requestString: String
): ApiResponse<LoginResponse> {
val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID)
?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.")
val stipulationPrivacyPolicy = stipulationRepository.findByIdOrNull(StipulationIds.PRIVACY_POLICY_ID)
?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.")
val request = objectMapper.readValue(requestString, SignUpRequest::class.java)
if (!request.isAgreePrivacyPolicy || !request.isAgreeTermsOfService) {
throw SodaException("약관에 동의하셔야 회원가입이 가능합니다.")
}
validatePassword(request.password)
duplicateCheckEmail(request.email)
duplicateCheckNickname(request.nickname)
val member = createMember(request)
member.profileImage = uploadProfileImage(profileImage = profileImage, memberId = member.id!!)
agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy)
return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = login(request.email, request.password))
}
fun login(request: LoginRequest): ApiResponse<LoginResponse> {
return ApiResponse.ok(
message = "로그인 되었습니다.",
data = login(request.email, request.password, request.isAdmin, request.isCreator)
)
}
fun getMemberInfo(member: Member, container: String): GetMemberInfoResponse {
return GetMemberInfoResponse(
can = member.getChargeCan(container) + member.getRewardCan(container),
isAuth = member.auth != null,
role = member.role,
messageNotice = member.notification?.message,
followingChannelLiveNotice = member.notification?.live,
followingChannelUploadContentNotice = member.notification?.uploadContent
)
}
private fun login(
email: String,
password: String,
isAdmin: Boolean = false,
isCreator: Boolean = false
): LoginResponse {
val member = repository.findByEmail(email = email) ?: throw SodaException("로그인 정보를 확인해주세요.")
if (!member.isActive) {
throw SodaException("탈퇴한 계정입니다.\n고객센터로 문의해 주시기 바랍니다.")
}
if (isCreator && member.role != MemberRole.CREATOR) {
throw SodaException("로그인 정보를 확인해주세요.")
}
if (isAdmin && member.role != MemberRole.ADMIN) {
throw SodaException("로그인 정보를 확인해주세요.")
}
val authenticationToken = UsernamePasswordAuthenticationToken(email, password)
val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
SecurityContextHolder.getContext().authentication = authentication
val jwt = tokenProvider.createToken(
authentication = authentication,
memberId = member.id!!
)
return LoginResponse(
userId = member.id!!,
token = jwt,
nickname = member.nickname,
email = member.email,
profileImage = member.profileImage ?: ""
)
}
private fun uploadProfileImage(profileImage: MultipartFile?, memberId: Long): String {
return if (profileImage != null) {
val metadata = ObjectMetadata()
metadata.contentLength = profileImage.size
s3Uploader.upload(
inputStream = profileImage.inputStream,
bucket = s3Bucket,
filePath = "profile/$memberId/${generateFileName(prefix = "$memberId-profile")}",
metadata = metadata
)
} else {
"profile/default-profile.png"
}
}
private fun agreeTermsOfServiceAndPrivacyPolicy(
member: Member,
stipulationTermsOfService: Stipulation,
stipulationPrivacyPolicy: Stipulation
) {
val termsOfServiceAgree = StipulationAgree(true)
termsOfServiceAgree.member = member
termsOfServiceAgree.stipulation = stipulationTermsOfService
stipulationAgreeRepository.save(termsOfServiceAgree)
val privacyPolicyAgree = StipulationAgree(true)
privacyPolicyAgree.member = member
privacyPolicyAgree.stipulation = stipulationPrivacyPolicy
stipulationAgreeRepository.save(privacyPolicyAgree)
}
private fun createMember(request: SignUpRequest): Member {
val member = Member(
email = request.email,
password = passwordEncoder.encode(request.password),
nickname = request.nickname,
gender = request.gender,
container = request.container
)
return repository.save(member)
}
private fun validatePassword(password: String) {
val passwordValidationMessage = validator.passwordValidation(password)
if (passwordValidationMessage.trim().isNotEmpty()) {
throw SodaException(passwordValidationMessage)
}
}
fun duplicateCheckEmail(email: String): ApiResponse<Any> {
validateEmail(email)
repository.findByEmail(email)?.let { throw SodaException("이미 사용중인 이메일 입니다.", "email") }
return ApiResponse.ok(message = "사용 가능한 이메일 입니다.")
}
private fun validateEmail(email: String) {
val emailValidationMessage = validator.emailValidation(email)
if (emailValidationMessage.trim().isNotEmpty()) {
throw SodaException(emailValidationMessage, "email")
}
}
fun duplicateCheckNickname(nickname: String): ApiResponse<Any> {
validateNickname(nickname)
repository.findByNickname(nickname)?.let { throw SodaException("이미 사용중인 닉네임 입니다.", "nickname") }
return ApiResponse.ok(message = "사용 가능한 닉네임 입니다.")
}
private fun validateNickname(nickname: String) {
val nicknameValidationMessage = validator.nicknameValidation(nickname)
if (nicknameValidationMessage.trim().isNotEmpty()) {
throw SodaException(nicknameValidationMessage, "nickname")
}
}
override fun loadUserByUsername(username: String): UserDetails {
val member = repository.findByEmail(email = username)
?: throw UsernameNotFoundException(username)
return MemberAdapter(member)
}
}

View File

@ -0,0 +1,40 @@
package kr.co.vividnext.sodalive.member
import org.springframework.stereotype.Component
@Component
class SignUpValidator {
fun emailValidation(email: String): String {
val isNotValidEmail = "^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$"
.toRegex(RegexOption.IGNORE_CASE)
.matches(email)
.not()
if (isNotValidEmail) {
return "올바른 이메일을 입력해 주세요"
}
return ""
}
fun nicknameValidation(nickname: String): String {
if (nickname.length < 2) {
return "닉네임은 2자 이상 입력해 주세요."
}
return ""
}
fun passwordValidation(password: String): String {
val isNotValidPassword = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d$@!%*#?&]{8,}$"
.toRegex()
.matches(password)
.not()
if (isNotValidPassword) {
return "영문, 숫자 포함 8자 이상의 비밀번호를 입력해 주세요."
}
return ""
}
}

View File

@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.member.auth
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import javax.persistence.CascadeType
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.OneToOne
@Entity
data class Auth(
@Column(nullable = false)
val name: String,
@Column(nullable = false)
val birth: String,
@Column(columnDefinition = "TEXT", nullable = false)
val uniqueCi: String,
@Column(columnDefinition = "TEXT", nullable = false)
val di: String
) : BaseEntity() {
@OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL])
@JoinColumn(name = "member_id", nullable = true)
var member: Member? = null
set(value) {
value?.auth = this
field = value
}
}

View File

@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.member.info
import kr.co.vividnext.sodalive.member.MemberRole
data class GetMemberInfoResponse(
val can: Int,
val isAuth: Boolean,
val role: MemberRole,
val messageNotice: Boolean?,
val followingChannelLiveNotice: Boolean?,
val followingChannelUploadContentNotice: Boolean?
)

View File

@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.member.login
data class LoginRequest(
val email: String,
val password: String,
val isAdmin: Boolean = false,
val isCreator: Boolean = false
)

View File

@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.member.login
data class LoginResponse(
val userId: Long,
val token: String,
val nickname: String,
val email: String,
val profileImage: String
)

View File

@ -0,0 +1,29 @@
package kr.co.vividnext.sodalive.member.notification
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.OneToOne
@Entity
data class MemberNotification(
@Column(nullable = false)
var uploadContent: Boolean? = true,
@Column(nullable = false)
var live: Boolean? = true,
@Column(nullable = false)
var message: Boolean? = true
) : BaseEntity() {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
var member: Member? = null
set(value) {
value?.notification = this
field = value
}
}

View File

@ -0,0 +1,13 @@
package kr.co.vividnext.sodalive.member.signUp
import kr.co.vividnext.sodalive.member.Gender
data class SignUpRequest(
val email: String,
val password: String,
val nickname: String,
val gender: Gender,
val isAgreeTermsOfService: Boolean,
val isAgreePrivacyPolicy: Boolean,
val container: String = "api"
)

View File

@ -0,0 +1,14 @@
package kr.co.vividnext.sodalive.member.stipulation
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
@Entity
data class Stipulation(
@Column(nullable = false)
val title: String,
@Column(columnDefinition = "TEXT", nullable = false)
var description: String,
var isActive: Boolean = true
) : BaseEntity()

View File

@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.member.stipulation
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
@Entity
data class StipulationAgree(
val isAgree: Boolean
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
var member: Member? = null
set(value) {
member?.stipulationAgrees?.add(this)
field = value
}
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "stipulation_id", nullable = false)
var stipulation: Stipulation? = null
}
object StipulationIds {
const val TERMS_OF_SERVICE_ID = 1L
const val PRIVACY_POLICY_ID = 2L
}

View File

@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.member.stipulation
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface StipulationRepository : JpaRepository<Stipulation, Long>
@Repository
interface StipulationAgreeRepository : JpaRepository<StipulationAgree, Long>

View File

@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.member.token
import org.springframework.data.annotation.Id
import org.springframework.data.redis.core.RedisHash
@RedisHash("MemberToken")
data class MemberToken(
@Id
val id: Long,
var tokenList: List<String>
)

View File

@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.member.token
import org.springframework.data.repository.CrudRepository
import org.springframework.stereotype.Repository
@Repository
interface MemberTokenRepository : CrudRepository<MemberToken, Long>

View File

@ -0,0 +1,34 @@
package kr.co.vividnext.sodalive.utils
import java.nio.charset.StandardCharsets
import java.security.SecureRandom
import java.util.Base64
import java.util.Random
import java.util.UUID
fun generatePassword(length: Int): String {
val characterSet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()1234567890"
val random = SecureRandom()
val password = StringBuilder()
for (i in 0 until length) {
val rIndex = random.nextInt(characterSet.length)
password.append(characterSet[rIndex])
}
return password.toString()
}
fun generateFileName(prefix: String = ""): String {
val timestamp = System.currentTimeMillis()
val random = Random().nextInt(10000)
val uuid = UUID.randomUUID().toString()
return if (prefix.isBlank()) {
Base64
.getUrlEncoder()
.encodeToString("$uuid-$random-$timestamp".toByteArray(StandardCharsets.UTF_8))
} else {
"$prefix-$uuid-$random-$timestamp"
}
}

View File

@ -1 +1,67 @@
server:
shutdown: graceful
logging:
level:
com:
amazonaws:
util:
EC2MetadataUtils: error
cloud:
aws:
credentials:
accessKey: ${APP_AWS_ACCESS_KEY}
secretKey: ${APP_AWS_SECRET_KEY}
s3:
bucket: ${S3_BUCKET}
cloudFront:
host: ${CLOUD_FRONT_HOST}
region:
static: ap-northeast-2
stack:
auto: false
jwt:
header: Authorization
token-validity-in-seconds: ${JWT_TOKEN_VALIDITY_TIME}
secret: ${JWT_SECRET}
spring:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
database: mysql
servlet:
multipart:
max-file-size: 1024MB
max-request-size: 1024MB
---
spring:
config:
activate:
on-profile: local
devtools:
restart:
enabled: true
livereload:
enabled: true
jpa:
properties:
hibernate:
show_sql: true
format_sql: true