diff --git a/.github/workflows/work.yml b/.github/workflows/work.yml new file mode 100644 index 0000000..057c515 --- /dev/null +++ b/.github/workflows/work.yml @@ -0,0 +1,63 @@ +name: 'work' + +on: + push: + branches: + - 'main' + +jobs: + build: + name: 이미지 빌드 및 도커허브 푸시 + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true && github.base_ref == 'main' + needs: update + steps: + - uses: actions/checkout@v3 + with: + ref: refs/heads/main + - name: application-prod.yml 생성 + env: + ACTIONS_STEP_DEBUG: true + APPLICATION_PROD: ${{ secrets.APPLICATION_PROD_YML }} + run: echo "$APPLICATION_PROD" > src/main/resources/application-prod.yml + - name: jdk 17 설치 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: 'gradle' + - name: gradlew 실행 권한 부여 + run: chmod +x gradlew + - name: gradle 빌드 + run: ./gradlew build --no-daemon + - name: build 폴더를 캐시에 저장 + uses: actions/upload-artifact@v3 + with: + name: build-artifact + path: build + retention-days: 1 + - name: 도커 이미지 빌드 및 푸시 + run: | + docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }} + docker build -t ${{ secrets.DOCKER_REPO }}/trip . + docker push ${{ secrets.DOCKER_REPO }}/trip + deploy: + name: 원격 서버에 배포 + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true && github.base_ref == 'main' + needs: build + steps: + - name: 원격 서버에 배포하기 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + password: ${{ secrets.SSH_PASSWORD }} + port: 2222 + script: | + docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }} + docker stop trip_app || true + docker rm trip_app || true + docker pull ${{ secrets.DOCKER_REPO }}/trip + docker run --name=trip_app --restart unless-stopped \ + -p 80:8080 -e TZ=Asia/Seoul -d ${{ secrets.DOCKER_REPO }}/trip \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8fbf3f8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM openjdk:17-jdk-alpine +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar +EXPOSE 8080 +ENTRYPOINT ["java","-jar","-Dspring.profiles.active=prod","/app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 09905f4..2482a7a 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' //Querydsl 추가 implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' @@ -33,11 +35,22 @@ dependencies { annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" - compileOnly 'org.projectlombok:lombok' + + // mysql runtimeOnly 'com.mysql:mysql-connector-j' + + // h2 + runtimeOnly 'com.h2database:h2' + + // lombok + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.projectlombok:lombok' + + // Jwt + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' } tasks.named('test') { diff --git a/src/main/java/com/api/trip/common/security/JwtToken.java b/src/main/java/com/api/trip/common/security/JwtToken.java new file mode 100644 index 0000000..b718f0d --- /dev/null +++ b/src/main/java/com/api/trip/common/security/JwtToken.java @@ -0,0 +1,15 @@ +package com.api.trip.common.security; + + +import lombok.Getter; + +@Getter +public class JwtToken { + private String accessToken; + private String refreshToken; + + public JwtToken(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/com/api/trip/common/security/JwtTokenFilter.java b/src/main/java/com/api/trip/common/security/JwtTokenFilter.java new file mode 100644 index 0000000..8a039b2 --- /dev/null +++ b/src/main/java/com/api/trip/common/security/JwtTokenFilter.java @@ -0,0 +1,34 @@ +package com.api.trip.common.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +@Slf4j +public class JwtTokenFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String accessToken = request.getHeader("accessToken"); + + + if (jwtTokenProvider.validateAccessToken(accessToken)) { // 토큰이 유효한 경우 and 로그인 상태 + Authentication authentication = jwtTokenProvider.getAuthenticationByAccessToken(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); // 세션설정 + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/api/trip/common/security/JwtTokenProvider.java b/src/main/java/com/api/trip/common/security/JwtTokenProvider.java new file mode 100644 index 0000000..5aade7e --- /dev/null +++ b/src/main/java/com/api/trip/common/security/JwtTokenProvider.java @@ -0,0 +1,96 @@ +package com.api.trip.common.security; + + +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +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.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtTokenProvider { + /** + * JWT 생성, 검증, 변환 + */ + + @Value("${custom.jwt.token.access-expiration-time}") + private long accessExpirationTime; + + private final Key key; + + @Autowired + public JwtTokenProvider(@Value("${custom.jwt.token.secret}") String secretKey) { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public JwtToken createJwtToken(String email, String authorities) { + + Date expirationTime = new Date(System.currentTimeMillis() + accessExpirationTime); + Claims claims = Jwts.claims().setSubject(email); + claims.put("roles", authorities); // + + String accessToken = Jwts.builder() + .setClaims(claims) // 아이디, 권한정보 + .setExpiration(expirationTime) // 만료기간 + .signWith(SignatureAlgorithm.HS256, key) + .compact(); + + return new JwtToken(accessToken, "refreshToken"); + } + + public boolean validateAccessToken(String accessToken) { + try { + parseToken(accessToken); + return true; + } + catch (final JwtException | IllegalArgumentException exception) { + return false; + } + } + + private Claims parseToken(final String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token).getBody(); + } + + public Authentication getAuthenticationByAccessToken(String accessToken) { + + Claims claims = parseToken(accessToken); + + /** + * TODO 예외처리 + * if (claims.get("roles") == null) + * 권한정보 없을 떄 예외처리 + */ + + + Collection authorities = + Arrays.stream(claims.get("roles").toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + UserDetails principal = new User(claims.getSubject(), "", authorities); + return new UsernamePasswordAuthenticationToken(principal, "", authorities); + + } +} diff --git a/src/main/java/com/api/trip/common/security/MemberSecurityService.java b/src/main/java/com/api/trip/common/security/MemberSecurityService.java new file mode 100644 index 0000000..d7f2388 --- /dev/null +++ b/src/main/java/com/api/trip/common/security/MemberSecurityService.java @@ -0,0 +1,20 @@ +package com.api.trip.common.security; + +import com.api.trip.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class MemberSecurityService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + return memberRepository.findByEmail(email).orElseThrow(); + } +} diff --git a/src/main/java/com/api/trip/common/security/SecurityConfig.java b/src/main/java/com/api/trip/common/security/SecurityConfig.java new file mode 100644 index 0000000..91ec4fc --- /dev/null +++ b/src/main/java/com/api/trip/common/security/SecurityConfig.java @@ -0,0 +1,53 @@ +package com.api.trip.common.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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; +import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter; + + +@RequiredArgsConstructor +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(securedEnabled = true) +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ + return http + .authorizeHttpRequests( + auth -> auth + .requestMatchers("/api/members/login", "/api/members/join").permitAll() + .requestMatchers(PathRequest.toH2Console()).permitAll() + .anyRequest().authenticated() + + ) + .csrf(csrf -> csrf.ignoringRequestMatchers(PathRequest.toH2Console()).disable()) + .cors(cors -> cors.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .headers((headers) -> headers + .addHeaderWriter(new XFrameOptionsHeaderWriter( + XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN))) + .addFilterBefore(new JwtTokenFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) + .build(); + } + + + + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } +} diff --git a/src/main/java/com/api/trip/domain/member/controller/MemberController.java b/src/main/java/com/api/trip/domain/member/controller/MemberController.java index 6cc6d36..735d64f 100644 --- a/src/main/java/com/api/trip/domain/member/controller/MemberController.java +++ b/src/main/java/com/api/trip/domain/member/controller/MemberController.java @@ -1,7 +1,13 @@ package com.api.trip.domain.member.controller; +import com.api.trip.domain.member.controller.dto.JoinRequest; +import com.api.trip.domain.member.controller.dto.LoginRequest; +import com.api.trip.domain.member.controller.dto.LoginResponse; import com.api.trip.domain.member.service.MemberService; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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.RestController; @@ -11,4 +17,16 @@ public class MemberController { private final MemberService memberService; + + @PostMapping("/join") + public ResponseEntity joinMember(@RequestBody JoinRequest joinRequest){ + memberService.join(joinRequest); + return ResponseEntity.ok().build(); + } + + @PostMapping("/login") + public ResponseEntity loginMember(@RequestBody LoginRequest loginRequest){ + LoginResponse loginResponse = memberService.login(loginRequest); + return ResponseEntity.ok().body(loginResponse); + } } diff --git a/src/main/java/com/api/trip/domain/member/controller/dto/JoinRequest.java b/src/main/java/com/api/trip/domain/member/controller/dto/JoinRequest.java new file mode 100644 index 0000000..255e3b7 --- /dev/null +++ b/src/main/java/com/api/trip/domain/member/controller/dto/JoinRequest.java @@ -0,0 +1,13 @@ +package com.api.trip.domain.member.controller.dto; + +import com.api.trip.domain.member.model.Member; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +public class JoinRequest { + + private String email; + private String password; + private String nickname; +} diff --git a/src/main/java/com/api/trip/domain/member/controller/dto/LoginRequest.java b/src/main/java/com/api/trip/domain/member/controller/dto/LoginRequest.java new file mode 100644 index 0000000..9ae02f1 --- /dev/null +++ b/src/main/java/com/api/trip/domain/member/controller/dto/LoginRequest.java @@ -0,0 +1,9 @@ +package com.api.trip.domain.member.controller.dto; + +import lombok.Getter; + +@Getter +public class LoginRequest { + private String email; + private String password; +} diff --git a/src/main/java/com/api/trip/domain/member/controller/dto/LoginResponse.java b/src/main/java/com/api/trip/domain/member/controller/dto/LoginResponse.java new file mode 100644 index 0000000..0c37761 --- /dev/null +++ b/src/main/java/com/api/trip/domain/member/controller/dto/LoginResponse.java @@ -0,0 +1,27 @@ +package com.api.trip.domain.member.controller.dto; + +import com.api.trip.common.security.JwtToken; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class LoginResponse { + + private String tokenType; + private String accessToken; + private String refreshToken; + + @Builder + private LoginResponse(String tokenType, String accessToken, String refreshToken){ + this.tokenType = "Bearer"; + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + public static LoginResponse of(JwtToken jwtToken) { + return LoginResponse.builder() + .accessToken(jwtToken.getAccessToken()) + .refreshToken(jwtToken.getRefreshToken()) + .build(); + } +} diff --git a/src/main/java/com/api/trip/domain/member/model/Member.java b/src/main/java/com/api/trip/domain/member/model/Member.java index de9fdb5..8617603 100644 --- a/src/main/java/com/api/trip/domain/member/model/Member.java +++ b/src/main/java/com/api/trip/domain/member/model/Member.java @@ -1,14 +1,23 @@ package com.api.trip.domain.member.model; +import com.api.trip.domain.member.controller.dto.JoinRequest; import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Member { +public class Member implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -27,4 +36,49 @@ public class Member { @Enumerated(EnumType.STRING) private MemberRole role; + @Builder + private Member(String email, String nickname, String password){ + this.email = email; + this.nickname = nickname; + this.password = password; + this.role = MemberRole.MEMBER; + } + + public static Member of(JoinRequest joinRequest, String password){ + return Member.builder() + .email(joinRequest.getEmail()) + .nickname(joinRequest.getNickname()) + .password(password) + .build(); + } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(MemberRole.MEMBER.getValue())); + } + + @Override + public String getUsername() { + return getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } } diff --git a/src/main/java/com/api/trip/domain/member/repository/MemberRepository.java b/src/main/java/com/api/trip/domain/member/repository/MemberRepository.java index a5c976f..3b37213 100644 --- a/src/main/java/com/api/trip/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/api/trip/domain/member/repository/MemberRepository.java @@ -3,5 +3,8 @@ import com.api.trip.domain.member.model.Member; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface MemberRepository extends JpaRepository { + Optional findByEmail(String email); } diff --git a/src/main/java/com/api/trip/domain/member/service/MemberService.java b/src/main/java/com/api/trip/domain/member/service/MemberService.java index 6ae3700..ed0d35c 100644 --- a/src/main/java/com/api/trip/domain/member/service/MemberService.java +++ b/src/main/java/com/api/trip/domain/member/service/MemberService.java @@ -1,14 +1,51 @@ package com.api.trip.domain.member.service; +import com.api.trip.common.security.JwtToken; +import com.api.trip.common.security.JwtTokenProvider; +import com.api.trip.domain.member.controller.dto.JoinRequest; +import com.api.trip.domain.member.controller.dto.LoginRequest; +import com.api.trip.domain.member.controller.dto.LoginResponse; +import com.api.trip.domain.member.model.Member; import com.api.trip.domain.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.stream.Collectors; + @Service @Transactional @RequiredArgsConstructor public class MemberService { private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final AuthenticationManagerBuilder authenticationManagerBuilder; + private final JwtTokenProvider jwtTokenProvider; + + + public void join(JoinRequest joinRequest) { + Member member = Member.of(joinRequest, passwordEncoder.encode(joinRequest.getPassword())); + memberRepository.save(member); + } + + public LoginResponse login(LoginRequest loginRequest) { + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginRequest.getEmail(), loginRequest.getPassword()); + Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); + + String authorities = authentication.getAuthorities().stream() + .map(a -> "ROLE_" + a.getAuthority()) + .collect(Collectors.joining(",")); + // 인증 성공 + // 토큰 생성 + JwtToken jwtToken = jwtTokenProvider.createJwtToken(authentication.getName(), authorities); + + + + return LoginResponse.of(jwtToken); + } } diff --git a/src/test/java/com/api/trip/TripApplicationTests.java b/src/test/java/com/api/trip/TripApplicationTests.java index 90b5e9a..a0fcd5a 100644 --- a/src/test/java/com/api/trip/TripApplicationTests.java +++ b/src/test/java/com/api/trip/TripApplicationTests.java @@ -3,7 +3,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest +//@SpringBootTest class TripApplicationTests { @Test