시큐리티 설정

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

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>