본문 바로가기
Android

Android JWT 인증 with Retrofit, Spring boot

by 들풀민들레 2022. 7. 27.

 

 

본 글은 수업시간에 나왔던 내용을 정리한 글입니다.

 

 

Back-End – Spring boot

 

table

 

 

회원 테이블은 위와 같이 선언되어 있다는 가정.

 

UserVO

 

클라이언트 요청 데이터 및 DB Select 데이터 추상화

 

public class UserVO {
	private String id;
	private String password;
	private String name;
	private String role;
	public UserVO() {
		super();
	}
	public UserVO(String id, String password, String name, String role) {
		super();
		this.id = id;
		this.password = password;
		this.name = name;
		this.role = role;
	}
	public String getId() {
		return id;
	}
	public void setId(String id) {
		this.id = id;
	}
	public String getPassword() {
		return password;
	}
	public void setPassword(String password) {
		this.password = password;
	}
	public String getName() {
		return name;
	}
	public void setName(String name) {
		this.name = name;
	}
	public String getRole() {
		return role;
	}
	public void setRole(String role) {
		this.role = role;
	}
	@Override
	public String toString() {
		return "UserVO [id=" + id + ", password=" + password + ", name=" + name + ", role=" + role + "]";
	}
	
	
}

 

JwtUserMapper

 

myBatis 연동을 위한  Mapper

 

@Mapper
public interface JwtUserMapper {
	public UserVO getUser(String id);
	public void insertUser(UserVO user);
}

 

CustomUserDetailsService

 

id 값으로 유저 정보를 db select 해서 JWT 에서 사용할 유저 정보(UserDetails) 로 만들어 주는 역할

Service Controller 에서 직접 사용하지 않고 Filter 에서 token 인증할때 사용하게 된다.

 

@Service
public class CustomUserDetailsService implements UserDetailsService {
	@Autowired
	private JwtUserMapper jwtUserMapper;

	@Override
	public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
		UserVO user = jwtUserMapper.getUser(id);
		return new org.springframework.security.core.userdetails.User(user.getId(), user.getPassword(),
				new ArrayList<>());
	}
}

 

 

AuthService

 

token을 생성해주는 역할, token으로부터 정보를 추출하는 역할, token의 유효성을 검사하는 역할을 한다.

 

@Service
public class AuthService {
	private String secret = "kkang";

	public String extractUsername(String token) {
		return extractClaim(token, Claims::getSubject);
	}

	public Date extractExpiration(String token) {
		return extractClaim(token, Claims::getExpiration);
	}

	public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
		final Claims claims = extractAllClaims(token);
		return claimsResolver.apply(claims);
	}

	private Claims extractAllClaims(String token) {
		return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
	}

	private Boolean isTokenExpired(String token) {
		return extractExpiration(token).before(new Date());
	}

	public String generateToken(String username) {
		Map<String, Object> claims = new HashMap<>();
		return createToken(claims, username);
	}

	////token 생성을 하는 createToken
	private String createToken(Map<String, Object> claims, String subject) {
		//claims라고 하는건 token으로 만들고 싶은 실질적인 값을 넣는 곳이고 
		//expiration은 token의 유효시간 설정을 하는 부분이다.(payload 부분)  
		//뒤에 나오는 sign 관련은 token의 signature를 설정하는 부분이고 이곳에서는 알고리즘과 secret key가 필요하다.(signature 부분)
		//이렇게 조합해서 마지막에 compact()를 수행하면 String으로 된 JWT가 생성이 된다. 
		return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis()))
				.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10))
				.signWith(SignatureAlgorithm.HS256, secret).compact();
	}

	public Boolean validateToken(String token, UserDetails userDetails) {
		final String username = extractUsername(token);
		return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
	}
}

 

JwtFilter

 

@Component
public class JwtFilter extends OncePerRequestFilter {
	@Autowired
	private AuthService authService;
	@Autowired
	private CustomUserDetailsService service;

	@Override
	protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
			FilterChain filterChain) throws ServletException, IOException {

		//request로부터 "Authorization" 이라는 Header 값을 추출해낸다. JWT를 발급받고 그 값을 Authorization이라는 
		//Header에 넣어서 다시 요청을 보낸 것이다. 
		String token = httpServletRequest.getHeader("Authorization");
	
		
		String userName = null;
		
		//authorizationHeader에 값이 있고 이 값이 "Bearer "로 시작한다면 authorizationHeader를 통해 token을 추출해낼수 있다. 
		//"Bearer "가 7자이기 때문에 위와 같이 substring을 한 것이고 추출한 token 값을 
		//jwtUtil 이라는 JWT를 발급 또는 해석해주는 클래스를 통해 userName을 추출한다. 
		if (token != null) {
			System.out.println("doFilterInternal.....2.......");
			userName = authService.extractUsername(token);
			System.out.println("JwtFilter : userName - "+userName);
		}
		//추출한 userName이 null이 아니라는것은 token의 값이 정상적이라는것을 나타내고, 
		//SecurityContextHolder (Spring Security의 필요한 값을 담는 공간)의 authentication이 비어 있다면 
		//이는 최초 인증이라는 뜻이므로 userName을 통해서 Spring Security Authentication에 필요한 정보를 setting 한다. 
		if (userName != null && SecurityContextHolder.getContext().getAuthentication() == null) {
			System.out.println("doFilterInternal.....3.......");
			UserDetails userDetails = service.loadUserByUsername(userName);
			if (authService.validateToken(token, userDetails)) {
				UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
						userDetails, null, userDetails.getAuthorities());
				usernamePasswordAuthenticationToken
						.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
				SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
			}
		}
		//작업이 끝났으면 다음 필터를 수행하기 위한 Chain을 태운다. 
		filterChain.doFilter(httpServletRequest, httpServletResponse);
	}
}

 

AuthController

 

클라이언트 요청을 처리하기 위한 Controller

 

@Controller
public class AuthController {
	@Autowired
	private AuthService authService;
	@Autowired
	private AuthenticationManager authenticationManager;
	@Autowired
	private PasswordEncoder passwordEncoder;
	
	@Autowired
	private JwtUserMapper jwtUserMapper;

	@GetMapping("/mobile/welcome")
	@ResponseBody
	public String welcome(@RequestParam String name) {
		return "Welcome "+name;
	}
	@GetMapping("/mobile/security")
	@ResponseBody
	public String security() {
		return "security page.....";
	}
	@PostMapping("/mobile/register.do")
	@ResponseBody
	public String registerUser(@RequestBody UserVO userVO) throws Exception {
		System.out.println("register controller :"+userVO.getId()+","+userVO.getPassword()+","+userVO.getName()+","+userVO.getRole());
		userVO.setPassword(passwordEncoder.encode(userVO.getPassword()));
		System.out.println("register controller :"+userVO.getId()+","+userVO.getPassword()+","+userVO.getName()+","+userVO.getRole());
		userVO.setRole("User");
		jwtUserMapper.insertUser(userVO);
		return "register success";
	}

	@PostMapping("/mobile/login.do")
	@ResponseBody
	public String generateToken(@RequestBody UserVO userVO) throws Exception {
		System.out.println("........."+userVO.getId()+", "+userVO.getPassword());
			authenticationManager.authenticate(
					new UsernamePasswordAuthenticationToken(userVO.getId(), userVO.getPassword()));
		return authService.generateToken(userVO.getId());
	}
}

 

SecurityConfig

 

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	@Autowired
	private CustomUserDetailsService userDetailsService;
	@Autowired
	private JwtFilter jwtFilter;

	public SecurityConfig() {
		super();
	}

	public SecurityConfig(boolean disableDefaults) {
		super(disableDefaults);
	}

	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.userDetailsService(userDetailsService);
	}

	@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
	@Override
	public AuthenticationManager authenticationManagerBean() throws Exception {
		return super.authenticationManagerBean();
	}

	@Bean
	@Primary
	public BCryptPasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}

	@Override
	public void configure(WebSecurity web) {
		web.ignoring().antMatchers(HttpMethod.OPTIONS, "/**")
				.antMatchers("/mobile/welcome", "/*.html", "/favicon.ico", "/**/*.do", "/**/*.css", "/**/*.js",
						"/h2-console/**");
	}

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		CharacterEncodingFilter filter = new CharacterEncodingFilter();

		filter.setEncoding("UTF-8");

		filter.setForceEncoding(true);

		http.addFilterBefore(filter, CsrfFilter.class);

		/// authenticate 로 들어오면 모두 허용, 그 외에는 인증을 거쳐야 한다는 내용이다.
		// 즉 /authenticate 인 경우에 JWT 를 발급해주는 로직이 들어가야 하고
		// 나머지 모든 로직은 JWT를 통해 인증이 된 경우만 사용할 수 있다고 보면 된다.
		http.csrf()
			.disable()
			.authorizeRequests()
			.antMatchers("/authenticate")
			.permitAll()
			.antMatchers("/register")
			.permitAll().anyRequest().authenticated().and().exceptionHandling().and().sessionManagement()
				.sessionCreationPolicy(SessionCreationPolicy.STATELESS);

		http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());


		// UsernamePasswordAuthenticationFilter를 통과하기 전에 jwtFilter 라는것을 지나도록 설정을 했다.
		// Spring Security에는 많은 filter가 있고 이를 순서대로 통과하며 인증에 대한 처리를 한다.
		// 인증을 하는데 있어서 jwtFilter라는 것을 추가해 request로부터 날아온 JWT를 처리하여 이에 대한 결과를 사용자 정보에 추가를
		// 하기 위함이다.
		http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

	}
}

 

 

Android with Retrofit

 

JWT 인증이 필요한 요청과 JWT 인증이 필요없는 요청 구분 테스트

로그인시 서버에서 전달하는 토큰을 SharedPreference 로 저장

인증이 필요한 경우 저장된 토큰을 Retrofit 요청시 헤더에 추가

 

build.gradle

 

implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.google.code.gson:gson:2.8.9'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.2.1'
implementation 'com.squareup.retrofit2:converter-scalars:2.9.0'

 

Api Service

 

interface ApiService {
    @GET("welcome")
    fun welcome(
        @Query("name") name: String
    ): Call<String>

    @GET("security")
    fun security(): Call<String>

    @POST("register.do")
    fun signup(
        @Body user: User
    ): Call<String>

    @POST("login.do")
    fun signin(
        @Body user: User
    ): Call<String>
}

 

로그인

 

로그인 결과로 서버에서 전달되는 토큰을 Preference 로 저장

 

val apiService = (applicationContext as MyApplication).apiService
val call = apiService.signin(User(id = binding.idView.text.toString(), password = binding.pwView.text.toString(), name = null))
call.enqueue(object : Callback<String>{
    override fun onResponse(call: Call<String>, response: Response<String>) {
        if(response.code() == 200){
            val token = response.body()!!
            val editor = (applicationContext as MyApplication).prefs.edit()
            editor.putString("token", token)
            editor.commit()
            Toast.makeText(this@LoginActivity, "signin success", Toast.LENGTH_SHORT).show()
            finish()
        }
    }

    override fun onFailure(call: Call<String>, t: Throwable) {
        t.printStackTrace()
        call.cancel()
    }
})

 

Retrofit 설정

 

인증이 필요한 요청을 구분하여 저장된 토큰을 헤더에 지정

 

val authInterceptor = object : Interceptor {
        override fun intercept(chain: Interceptor.Chain): Response {
            val token = prefs.getString("token", "") ?: ""
            Log.d("kkang","token:$token:")

            Log.d("kkang","${chain.request().url.toString()}")
            val url = chain.request().url.toString()
            if(url.contains("welcome") || url.contains(".do")){
                return chain.proceed(chain.request())
            }else {
                val request = chain.request().newBuilder()
                    .addHeader("Authorization", "$token")
                    .build()
                return chain.proceed(request)
            }


        }
    }

    val retrofit: Retrofit
        get() {

            val builder = OkHttpClient.Builder()

            builder.addInterceptor(authInterceptor)

            val interceptor = HttpLoggingInterceptor()
            interceptor.level = HttpLoggingInterceptor.Level.BODY
            builder.addInterceptor(interceptor)

            val client = builder.build()

            val gson = GsonBuilder()
                .setLenient()
                .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS")
                .create()

            return Retrofit.Builder()
                .baseUrl("http://10.0.2.2:8080/mobile/")
                .client(client)
                .addConverterFactory(ScalarsConverterFactory.create())
                .addConverterFactory(GsonConverterFactory.create(gson))
                .build()

        }