개발세발은 안되요

[Spring Boot] Google OAuth 2. 소셜 로그인 구현 , Redis 이용 본문

백엔드/Spring Boot

[Spring Boot] Google OAuth 2. 소셜 로그인 구현 , Redis 이용

금호박 2023. 12. 27. 15:40

 Papers 라는 프로젝트에서 로그인 기능을 구현해야 했고, 아직 소셜 로그인을 구현해본 적이 없어서 이 부분을 담당하게 되었습니다. 처음 해보는 것이어서 많이 막히기도 했고,,, 여러 블로그 참고해서 어찌저찌 구현은 완료했습니다 ㅎㅎ 

사용 스택

  • Spring Boot
  • JWT
  • OAuth 2.0
  • Redis : 토큰 관리 목적
  • MySQL

로그인 과정

이런 플로우로 작동한다.

개인적으로 로그인 흐름을 처음에 파악하는 것이 조금 어려웠습니다. 그래서 이런 저런 블로그들을 많이 참고해서 나름대로 정리해보았는데요!

여러 포스트들을 참고했기 때문에 비슷한 그림이 있을 수도? 있어용

음 로그인 과정에 대한 이론은 언젠가 다시 더 자세하게 작성해보고 싶습니다. 우선 구현을 어떻게 해야 하는지가 주제이기 때문에 구체적인 구현 위주로 이어가도록 하겠습니다!


구글 서비스 생성

https://myanjini.tistory.com/entry/GCP-OAuth-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EC%83%9D%EC%84%B1

 

GCP OAuth 클라이언트 생성

#1 Google Cloud Platform 접속 https://console.cloud.google.com/ Google Cloud Platform 하나의 계정으로 모든 Google 서비스를 Google Cloud Platform을 사용하려면 로그인하세요. accounts.google.com #2 프로젝트 생성 또는 선택

myanjini.tistory.com

저는 이 게시글을 참고하였는데요, OAuth를 이용하기 위해서는 꼭 생성하고 연결해두셔야 합니다!

그리고 이후 프로젝트가 진행됨에 따라 '승인된 자바스크립트 원본' 이나 '승인된 리디렉션 URL'에 필요한 주소를 추가거나 변경하여 이용하시면 됩니다.

 

 최종적으로 제 프로젝트에서 사용한 주소들은 

이 주소들 모두 이용하는 것은 아닙니다. 그냥 이런 변경사항이 계속 있다,,, 정도만 봐주세요 ^^

이렇게 있습니다! 나중에 배포 이후에 프론트에서 요구하는 주소로 변경해주시면 됩니다.

다음으로 실제 코드들 알아보도록 하겠습니다! 주석을 참고해주시면 더 좋을 것 같아요 :)

 

그리고 프로젝트 구조는 패키지 경로 참고해주시거나 깃허브 참고해주시면 도움이 될 것 같습니다!


application.yml

spring:
  redis:
    host:     # 배포 전,, 그러니까 로컬에서는 지우셔도 됩니다.
    port: 6379
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: 
    username: 
    password: 
  jpa:
    hibernate:
      ddl-auto: update   
    generate-ddl: true
    show-sql: true
  mvc:
    hidden-method:
      filter:
        enabled: true
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 
            client-secret: 
            redirect-uri: http://locahost:8080/auth # 설정하기 나름이에요!
            scope: profile, email
  profiles:
    include: oauth
  OAuth2:
    google:
      url:
        login: https://accounts.google.com/o/oauth2/v2/auth
        token: https://oauth2.googleapis.com/token
        profile: https://www.googleapis.com/oauth2/v3/userinfo

  
  level:
    com:
      amazonaws:
        util:
          EC2MetadataUtils: error

jwt:
  token :
    secret: 
    refresh-token-validity-in-seconds: 1209600000 # 2주 /설정하기 나름이에요!
    access-token-validity-in-seconds: 3600000 # 1시간 /설정하기 나름이에요!

 사실 이 포스팅에서는 로그인 구현에 집중하려고 합니다. 그래서 리프레시 토큰을 이용한 토큰 재발급은 이후 다른 포스트에서 다루거나, 이 게시글에 추가할 계획입니다.(만약 이 게시글에 추가된다면 이런 말은 모두 삭제되겠죠! ^*^)

 

그래도 조금만 쉽게 말씀드리고 가자면,

refresh token : access token 을 재발급받기 위해 이용되는 token.

access token : 인가 token. 구글로부터 회원 정보를 받아오기 위해 필요한 token.

보안을 위해 access token에 만료시간을 설정해 놓고, 일정 시간이 흐른 후에는 토큰을 재발급받아야 하도록 구현합니다. 이때 새로운 access token을 발급받기 위해 필요한 것이 refresh token 입니다.

즉 access token이 만료되면 refresh token을 이용해서 새로운 access token을 발급받아야 하는 것인데요, 

따라서 refresh token 의 유효기간을 access token의 유효기간보다 길게 설정해둡니다. 


Member.java

package efub.toy2.papers.domain.member.domain;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class Member extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column
    private Long memberId;

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    private String nickname;

    @Column
    private String introduce;

    @Column
    private String profileImgUrl;

    @Enumerated(EnumType.STRING)
    @Column
    private Role role;

    @Builder
    public Member(String email, String nickname , String introduce, Role role){
        this.email = email;
        this.nickname = nickname;
        this.introduce = introduce;
        this.role = role;
    }

    /* 유저 정보 설정 */
    public void setMemberInfo(String nickname , String introduce , String profileImgUrl){
        this.nickname = nickname;
        this.introduce = introduce;
        this.profileImgUrl = profileImgUrl;
    }


}

domain 에 해당하는 부분이어서 참고 정도만 해주시면 될 것 같습니다!


Role.java

package efub.toy2.papers.domain.member.domain;

@Getter
@RequiredArgsConstructor
public enum Role {
    GUEST("ROLE_QUEST","손님"),
    ADMIN("ROLE_USER","일반 사용자");

    private final String key;
    private final String title;
}

이번 프로젝트에서 로그인한 유저와 게스트 유저를 구분하기로 결정했기 때문에 구현해놓은 부분입니다. 참고 정도만 해주시면 될 것 같습니다. 일단 구글 로그인에 성공하는 것이 목표이기 때문에, 다른 부분들은 이후 다른 포스트에서 다룰 것 같아요!


GoogleUser.java

package efub.toy2.papers.domain.member.oauth;


@Data
public class GoogleUser {
    public String sub;
    public String email;
    public Boolean email_verified;
    public String name;
    public String family_name;
    public String given_name;
    public String picture;
    public String locale;
    public String hd;
}

GoogleOauthToken.java

package efub.toy2.papers.domain.member.oauth;

@Data
public class GoogleOauthToken {
    private String access_token;
    private int expires_in;
    private String scope;
    private String token_type;
    private String id_token;
}

MemberController.java

@Slf4j
@RestController
@RequestMapping
@RequiredArgsConstructor
public class MemberController {
    private final MemberService memberService;
    private final AuthService authService;
    private final FollowService followService;

    /* 로그인 */
    @PostMapping("/auth/login")
    public LoginResponseDto login(@RequestBody LoginRequestDto requestDto) throws IOException{
        LoginResponseDto loginResPonseDto= authService.googleLogin(requestDto.getCode());
        return loginResPonseDto;
    }
}

 로그인에 필요한 코드들만 옮겨왔습니다. 전체 코드는 이 포스트 아래의 깃허브 참고해주세면 좋을 것 같아요!

사실 저는 리프레시 토큰과 엑세스 토큰을 모두 이용하기 때문에 토큰 재발급 api도 구현되어 있습니다. 하지만 우선 로그인 구현에만 집중해보록 하겠습니다. 이후에 다른 포스트에서 토큰 재발급 api를 다루거나, 지금 포스트에 추가해두도록 하겠습니다!

 

로그인 api를 호출할 때 LoginRequestDto를 받는 것을 보실 수 있습니다.

더보기

LoginRequestDto.java

@Getter
public class LoginRequestDto {
    @NotNull
    private String code;
}

앞서 로그인 흐름에서 code 값을 프론트에서 받아온다고 했는데, 그 부분이라고 생각하시면 됩니다.

 

이후에 LoginResponseDto를 반환하는데요,

더보기

LoginResponseDto.java

@Getter
public class LoginResponseDto {

    private Boolean isExist;
    private String email;
    private String nickname;
    private String accessToken;

    @Builder
    public LoginResponseDto(Boolean isExist, Member member, String accessToken){
        this.isExist = isExist;
        this.email = member.getEmail();
        this.nickname = member.getNickname();
        this.accessToken = accessToken;
    }
}

사실 로그인에 대한 응답으로 무엇을 반환할지는 구현하기 나름이라고 생각합니다. 저의 경우 앞의 그림처럼 유저 정보와 기존 회원인지, 신규 회원인지에 대한 정보, 그리고 access token 을 반환해주었습니다.


AuthService.java

package efub.toy2.papers.domain.member.service;

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
@PropertySource("classpath:application.yml")
public class AuthService {

    private final JwtTokenProvider jwtTokenProvider;
    private final MemberService memberService;
    private final RedisService redisService;

    @Value("${spring.security.oauth2.client.registration.google.client-id}")
    private String GOOGLE_SNS_CLIENT_ID;

    @Value("${spring.security.oauth2.client.registration.google.redirect-uri}")
    private String GOOGLE_SNS_CALLBACK_URL;

    @Value("${spring.security.oauth2.client.registration.google.client-secret}")
    private String GOOGLE_SNS_CLIENT_SECRET;

    @Value("${spring.OAuth2.google.url.token}")
    private String GOOGLE_TOKEN_REQUEST_URL;

    @Value("${spring.OAuth2.google.url.profile}")
    private String GOOGLE_USERINFO_REQUEST_URL;


    public LoginResponseDto googleLogin(String code) throws IOException {
        GoogleOauthToken oauthToken = getAccessToken(code);

        GoogleUser googleUser = getUserInfo(oauthToken);

        /* 이메일을 통해 이미 회원가입된 멤버인지 확인한다. */
        String email = googleUser.getEmail();
        Boolean isExistingMember = memberService.checkJoined(email);

        Member member;
        /* 회원가입되어 있지 않은 멤버의 경우, 회원가입 */
        if(!isExistingMember){
            member = memberService.saveMember(googleUser);
        }
        /* 이미 회원가입되어 있는 멤버의 경우, 이메일을 통해 멤버 조회 */
        else{
            member = memberService.findMemberByEmail(email);
        }

        /* 회원 인가 처리를 위한 토큰 발행 */
        TokenResponseDto tokenDto = jwtTokenProvider.createToken(member.getEmail());

        redisService.setValues(member.getEmail(),tokenDto.getRefreshToken(),
                jwtTokenProvider.getTokenExpirationTime(tokenDto.getRefreshToken()));

        return new LoginResponseDto(isExistingMember, member, tokenDto.getAccessToken());
    }


    /* 일회용 code 를 구글로 보냄 -> 액세스 토큰을 포함한 JSON String 이 담긴 응답을 받아옴 */
    private GoogleOauthToken getAccessToken(String code) throws JsonProcessingException {
        Map<String,Object> params = new HashMap<>();
        params.put("code",code);
        params.put("client_id" , GOOGLE_SNS_CLIENT_ID);
        params.put("client_secret",GOOGLE_SNS_CLIENT_SECRET);
        params.put("redirect_uri", GOOGLE_SNS_CALLBACK_URL);
        params.put("grant_type","authorization_code");

        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<String> response = restTemplate.postForEntity(GOOGLE_TOKEN_REQUEST_URL,
                params, String.class);

        ObjectMapper objectMapper = new ObjectMapper();
        GoogleOauthToken googleOauthToken = objectMapper.readValue(response.getBody() , GoogleOauthToken.class);
        return googleOauthToken;
    }

    private GoogleUser getUserInfo(GoogleOauthToken oauthToken) throws JsonProcessingException{

        /* header 에 accessToken 담기 */
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add("Authorization","Bearer "+oauthToken.getAccess_token());

        /* HttpEntity 생성 -> 헤더 담음 -> restTemplate 으로 구글과 통신 */
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(httpHeaders);

        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<String> response = restTemplate.exchange(GOOGLE_USERINFO_REQUEST_URL , HttpMethod.GET , request , String.class);

        ObjectMapper objectMapper = new ObjectMapper();
        GoogleUser googleUser = objectMapper.readValue(response.getBody() , GoogleUser.class);
        return googleUser;
    }

    private HashMap<String, String> getPayloadByToken(String accessToken) {
        try {
            String[] splitJwt = accessToken.split("\\.");

            Base64.Decoder decoder = Base64.getDecoder();
            String payload = new String(decoder.decode(splitJwt[1].getBytes()));

            return new ObjectMapper().readValue(payload , HashMap.class);
        } catch (JsonProcessingException e){
            log.error(e.getMessage());
            return null;
        }
    }

}

여기에도 토큰 재발급 관련 코드는 빠져 있습니다. 전체 코드는 깃허브 참고해주세요!


JwtTokenProvider.java

package efub.toy2.papers.domain.member.service;

@Slf4j
@Service
@RequiredArgsConstructor
@PropertySource("classpath:application.yml")
public class JwtTokenProvider {

    private final MemberRepository memberRepository;

    @Value("${jwt.token.secret}")
    private String SECRET_KEY;

    @Value("${jwt.token.access-token-validity-in-seconds}")
    private Long ACCESS_TOKEN_VALID_TIME;

    @Value("${jwt.token.refresh-token-validity-in-seconds}")
    private Long REFRESH_TOKEN_VALID_TIME;

    @PostConstruct
    protected void init(){
        SECRET_KEY = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes());
    }


    /* JWT 토큰 : 생성 */
    public TokenResponseDto createToken(String email) {
        Date now = new Date();
        Claims claims = Jwts.claims().setSubject(email);

        String accessToken = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME))
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .compact();

        String refreshToken = Jwts.builder()
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime()+REFRESH_TOKEN_VALID_TIME))
                .signWith(SignatureAlgorithm.HS256 , SECRET_KEY)
                .compact();

        return new TokenResponseDto(accessToken,refreshToken);
    }

    /* JWT 토큰 : 인증 정보 조회 */
    public Authentication getAuthentication(String token){
        try{
            String email = Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody().getSubject();
            Member member = memberRepository.findByEmail(email).orElseThrow(()->new CustomException(ErrorCode.NO_MEMBER_EXIST));
            return new UsernamePasswordAuthenticationToken(member,"");
        } catch (ExpiredJwtException e){
            throw new CustomException(ErrorCode.EXPIRED_TOKEN);
        } catch (JwtException e){
            throw new CustomException(ErrorCode.INVALID_TOKEN);
        } catch (IllegalArgumentException e){
            throw new CustomException(ErrorCode.NON_LOGIN);
        }
    }

    /* Request 의 Header 에서 token 획득 */
    public String resolveToken(HttpServletRequest request){
        return request.getHeader("Authorization");
    }

    /* 토큰의 유효성과 만료일자 확인 -> 토큰의 expire 여부를 boolean 으로 반환 */
    public boolean validateToken(String jwtToken){
        try{
            Jws<Claims> claims = Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (ExpiredJwtException e){
            log.info(ErrorCode.EXPIRED_TOKEN.getMessage());
        } catch (JwtException e){
            log.info(ErrorCode.INVALID_TOKEN.getMessage());
        } catch (IllegalArgumentException e){
            log.info(ErrorCode.NON_LOGIN.getMessage());
        }
        return false;
    }

    public Long getTokenExpirationTime(String token){
        return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody().getExpiration().getTime();
    }

    /* 토큰으로부터 닉네임 획득 */
    public String getNicknameFromToken(String accessToken){
        String email = Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(accessToken).getBody().getSubject();
        Member member = memberRepository.findByEmail(email)
                .orElseThrow(()->new CustomException(ErrorCode.NO_MEMBER_EXIST));
        String nickname = member.getNickname();
        return nickname;
    }
}

 

더보기

TokenResponseDto.java

package efub.toy2.papers.domain.member.dto.response;

@Getter
public class TokenResponseDto {
    private String accessToken;
    private String refreshToken;

    public TokenResponseDto(String accessToken, String refreshToken){
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
    }
}

MemberRepository.java

package efub.toy2.papers.domain.member.repository;


public interface MemberRepository extends JpaRepository<Member , Long> {
   Optional<Member> findByEmail(String email);

   Boolean existsMemberByEmail(String email);

   Boolean existsMemberByNickname(String nickname);

   Optional<Member> findByNickname(String nickname);

   List<Member> findAllByMemberIdIsNot(Long memberId);
}

 


MemberService.java

package efub.toy2.papers.domain.member.service;

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;
    
    /* 멤버 생성 */
    public Member saveMember(@RequestBody  GoogleUser googleUser) {
        Member member = Member.builder()
                .email(googleUser.getEmail())
                .nickname(googleUser.getEmail())
                .role(Role.ADMIN)
                .build();
        memberRepository.save(member);
        return member;
    }

    /* 신규 회원인지 조사 */
    @Transactional(readOnly = true)
    public Boolean checkJoined(String email) {
        System.out.println("checkJoined emailL "+email);
        Boolean isJoined = memberRepository.existsMemberByEmail(email);
        return isJoined;
    }

    /* 이메일로 멤버 조회 */
    @Transactional(readOnly = true)
    public Member findMemberByEmail(String email) {
        return memberRepository.findByEmail(email)
                .orElseThrow(()->new CustomException(ErrorCode.NO_MEMBER_EXIST));
    }
}

 


RedisService.java

package efub.toy2.papers.global.service;

@Service
@Transactional
@RequiredArgsConstructor
public class RedisService {

    private final RedisTemplate<String, String> redisTemplate;

    /* 만료 시간이 지나면, 자동 삭제 */
    public void setValues(String key, String value, Long timeOut){
        redisTemplate.opsForValue().set(key,value,timeOut, TimeUnit.MILLISECONDS);
    }

    @Transactional(readOnly = true)
    public String getValues(String key){
        return redisTemplate.opsForValue().get(key);
    }

    @Transactional(readOnly = true)
    public Boolean checkValues(String key){
        return redisTemplate.hasKey(key);
    }
}

 

추가로, 이 부분도 후에 구현해주세요!

더보기

RedisRepositoryConfig.java

package efub.toy2.papers.global.config;

@Configuration
@RequiredArgsConstructor
@EnableRedisRepositories
public class RedisRepositoryConfig {
    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory(){
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName(host);
        redisStandaloneConfiguration.setPort(port);

        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }

    @Bean
    public RedisTemplate<String , Object> redisTemplate(){
        RedisTemplate<String , Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());

        return redisTemplate;
    }
}

여기까지 작성했다면 거의 다 온 것인데요,,,! 아직 더 작성해야 하는 부분들이 남아 있습니다.

더보기

JwtAuthenticationFilter.java

package efub.toy2.papers.global.config;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        /* 헤더에서 JWT 를 받아옵니다. */
        String token = jwtTokenProvider.resolveToken(request);

        /* 유효한 토큰인지 확인합니다. 유효성검사 */
        if (token != null && jwtTokenProvider.validateToken(token)) {
            /* 토큰 인증과정을 거친 결과를 authentication 이라는 이름으로 저장해줌. */
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            /* SecurityContext 에 Authentication 객체를 저장합니다. */
            /* token 이 인증된 상태를 유지하도록 context(맥락)을 유지해줌 */
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        /* UsernamePasswordAuthenticationFilter 로 이동 */
        filterChain.doFilter(request, response);
    }
}

 

더보기

WebConfig.java

package efub.toy2.papers.global.config;

import lombok.RequiredArgsConstructor;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final AuthUserArgumentResolver authUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolverList){
        argumentResolverList.add(authUserArgumentResolver);
    }
}

후기

개인적으로 이번에 로그인을 구현하는 것이 매우 어려웠습니다. 이해하는 데에 시간도 오래 걸렸고, 아직 이것과 관련해 더 공부해야 할 부분들이 아주 많이 남아 있습니다. 

 

왜 그럴까에 대해 생각해봤을 때 백 혼자서만 구현할 수 없는 기능이기 때문이라는 생각이 듭니다 ㅎㅎ 프론트로부터 어떤 작업이 수행된 후에 백으로 나머지 작업들이 넘겨지기 때문에 프론트를 아예 모르는 제가 더 이해하기 힘든 부분들이 많았던 것 같고, 또 그렇기 때문에 postman으로 테스트를 어떻게 해보아야 할 지 잘 모르겠었기 때문에 더 어렵게 느껴졌던 것 같습니다.

 

음 후에 프론트 구현 없이 포스트맨으로 테스트하는 방법 관련해서 게시글 올리도록 하겠습니다. 사실 code 값만 얻어오면 되는 것인데, 생각보다 꽤 많이 해매었습니다 ㅎㅎ 

 

아직 공부해야 하는 것이 아주 많이 남아 있지만 그래도 이 게시글이 저처럼 여러 블로그들을 떠돌아다니면서 구글 로그인 구현을 고민하고 있는 분들께 조금의 도움이나마 된다면 좋을 것 같아요! 

이후에 토큰 재발급 등 다른 기능 구현 관련해서도 게시글 올려보도록 하겠습니다.

 

아래는 전체 코드 확인하실 수 있는 깃허브 링크입니다!

https://github.com/EFUB-Papers/Papers-Back

 

GitHub - EFUB-Papers/Papers-Back

Contribute to EFUB-Papers/Papers-Back development by creating an account on GitHub.

github.com