[코드로 배우는 스프링 웹 프로젝트 개정판] 7장 정리

스프링 시큐리티를 이용해 로그인, 로그아웃, 자동 로그인, 인증/권한 처리, JDBC 인증/권한 처리, 사용자 정의 UserDetailsService, JSP에서의 사용 등을 처리할 수 있다. 이는 POST 방식, JDBC, BCryptPasswordEncoder, CustomUserDetailsService, Ajax 등을 활용하며, 스프링 시큐리티의 어노테이션과 표현식을 사용한다. 또한, 스프링 시큐리티는 CSRF 토큰 처리와 암호화 처리를 지원하며, 이를 통해 보안을 강화할 수 있다.
DriedPollack's avatar
Mar 28, 2024
[코드로 배우는 스프링 웹 프로젝트 개정판] 7장 정리

🌼Spring Web Security 소개

💡핵심 키워드

  • 스프링 시큐리티의 기본 동작 방식은 서블릿의 여러 종류 필터와 인터셉터를 이용해서 처리된다.
    • 필터는 서블릿에서 말하는 단순한 필터를 의미하고, 인터셉터는 스프링에서 필터와 유사한 역할을 한다.
    • 필터는 특정한 서블릿이나 컨트롤러의 접근에 관여한다는 점에서는 유사하지만 결정적인 차이를 구분하자면 필터는 스프링과 무관하게 서블릿 자원이고, 인터셉터는 스프링의 빈으로 관리되면서 스프링의 컨텍스트 내에 속한다는 차이다.
  • 스프링 시큐리티는 현재 동작하는 스프링 컨텍스트 내에서 동작하기 때문에 이미 컨텍스트에 포함된 여러 번들을 같이 이용해서 다양한 방식의 인증 처리가 가능하도록 설계할 수 있다.

Spring Web Security의 설정

  • 스프링 시큐리티 관련된 태그 라이브러리를 사용할 수 있도록 pom.xmlspring-security-taglib을 추가한다.
  • 스프링 시큐리티는 단독으로 설정할 수 있기 때문에 기존의 xml과는 별도로 security-context.xml을 따로 작성하는 것이 좋다.
  • XML을 이용해서 스프링 시큐리티를 설정할 때에는 5.0 네임스페이스에서 문제가 발생하기 때문에 빈즈의 수정이 필요하다.
  • 스프링 시큐리티가 스프링 MVC에서 사용되기 위해서는 필터를 이용해서 스프링 동작에 관여하도록 설정이 필요하다.
    • 스프링 시큐리티가 동작하기 위해서는 Authentication Manager라는 존재와 스프링 시큐리티의 시작 지점이 필요하다.

인증과 권한 부여

  • 인증은 자기 스스로가 무언가 자신을 증명할 만한 자료를 제시하는 것이다.
  • 권한 부여는 남에 의해서 자격이 부여된다는 것이다.
  • 스프링 시큐리티의 내부에도 이와 비슷한 구조를 가지고 있다.
    • AuthenticationManager는 다양한 방식의 인증을 처리할 수 있는 구조로 설계되어 있다.
    • ProviderManager는 인증에 대한 처리를 AuthenticationProvider라는 타입의 객체를 이용해서 처리를 위임한다.
    • AuthenticationProvider는 실제 인증 작업을 진행한다. 이때 인증된 정보에는 권한에 대한 정보를 같이 전달하게 되는데 이 처리는 UserDetailsService라는 존재와 관련이 있다.
    • UserDetailsService 인터페이스의 구현체는 사용자의 정보와 사용자가 가진 권한의 정보를 처리해서 반환한다.
  • 개발자가 스프링 시큐리티를 커스터마이징하는 방식은 크게 AuthenticationProvider를 직접 구현하는 방식과 실제 처리를 담당하는 UserDetailsService 를 구현하는 방식으로 나누어진다.
 

🌼로그인과 로그아웃 처리

💡핵심 키워드

접근 제한 설정

  • 특정한 URI에 접근할 때 인터셉터를 이용해서 접근을 제한하는 설정은 <security:intercept-url>을 이용한다.
    • <security:intercept-url>pattern이라는 속성과 access라는 속성을 지정해야 한다.
      • pattern은 URI의 패턴을 의미하고, access는 권한을 체크한다.

단순 로그인 처리

  • 스프링 시큐리티에서 명심해야 하는 사항 중 하나는 스프링 시큐리티가 사용하는 username이나 User라는 용어의 의미가 일반적인 시스템에서의 의미와 차이가 있다는 점이다.
    • 보통 시스템에서 사용자 아이디를 의미하는 userid는 스프링 시큐리티에서는 username에 해당한다. 일반적으로 사용자의 이름은 username이라고 처리하는 것과 혼동하면 안된다.
    • User라는 용어 역시 혼란의 여지가 있다. 스프링 시쿠리티의 User는 인증 정보와 권한을 가진 객체이므로 일반적인 경우에 사용하는 사용자 정보와는 다른 의미다.
  • 스프링 시큐리티 5버전부터 반드시 PasswordEncoder 의 지정이 반드시 필요하다. 만일 패스워드의 인코딩 처리 없이 사용하고 싶다면 패스워드 앞에 {noop} 문자열을 추가한다.
  • 스픟링 시큐리티를 학습하다 보면 매번 로그아웃하고 새롭게 로그인을 해야 하는 사오항이 자주 발생한다.
    • 개발자 도구에서 Application 탭을 확인해 보면 Cookies 항목에 JSESSIONID와 같이 세션을 유지하는데 사용되는 세션 쿠키의 존재를 확인할 수 있다.
    • 로그아웃은 JSESSIONID 쿠키를 강제로 삭제해서 처리한다.
  • <security:access-denied-handler>org.springframework.security.web.access.AccessDeniedHandler 인터페이스의 구현체를 지정하거나 error-page를 지정할 수 있다.
  • Access Denied의 경우는 403 에러 메시지가 발생한다. JSP에서는 HttpServletRequest 안에 SPRING_SECURITY_403_EXCEPTION이라는 이름으로 AccessDeniedException 객체가 전달된다.
  • 접근 제한이 된 경우에 다양한 처리를 하고 싶다면 AccessDeniedHandler 인터페이스를 구현하는 편이 좋다.
    • 이 경우 servlet-contextg.xml에서는 error-page 속성 대신에 CustomAccessDeniedHandler를 빈으로 등록해서 사용한다.
    • <security:access-denied-handler>error-page 속성과 ref 속성 둘 중 하나만을 사용한다.

커스텀 로그인 페이지

  • 거의 대부분의 경우 별도로 URI를 이용해서 로그인 페이지를 다시 제작해서 사용한다.
  • 이 경우 <form> 태그의 action 속성값을 /login으로 지정하고 반드시 POST 방식으로 데이터를 전송해야 한다.

CSRF(Cross-site request forgery) 공격과 토큰

  • 스프링 시큐리티에서 POST 방식을 이용하는 경우 기본적으로 CSRF 토큰을 이용하게 된다. 별도의 설정이 없다면 스프링 시큐리티가 적용된 사이트의 모든 POST 방식에는 CSRF 토큰이 사용되는데 사이트간 위조 방지를 목적으로 특정한 값의 토큰을 사용하는 방식이다.
  • CSRF 공격은 사이트간 요청 위조라고 번역될 수 있다. 서버에서 받아들이는 정보가 특별히 사전 조건을 검증하지 않는다는 단점을 이용하는 공격 방식이다.
  • CSRF는 <img> 태그 등의 URI 등을 이용할 수 있기 때문에 손쉽게 공격할 수 있는 방법이 된다.
  • 이를 막기 위해서 여러 방식이 존재한다.
    • 사용자의 요청에 대한 출처를 의미하는 referer 헤더를 체크하거나 일반적인 경우에 잘 사용되지 않고 REST 방식에서 사용되는 PUT, DELETE와 같은 방식을 이용하는 등의 방식을 고려해 볼 수 있다.
  • CSRF 토큰은 사용자가 임의로 변하는 특정한 토큰값을 서버에서 체크하는 방식이다.
    • 사용자가 POST 방식 등으로 특정한 작업을 할 때는 브라우저에서 전송된 CSRF 토큰의 값과 서버가 보관하고 있는 토큰의 값을 비교한다. 만일 CSRF 토큰의 값이 다르다면 작업을 처리하지 않는 방식이다.

로그인 성공과 AuthenticationSuccessHandler

  • 로그인을 처리하다 보면 로그인 성공 이후에 특정한 동작을 하도록 제어하고 싶은 경우가 있다.
  • 이런 경우를 위해서 스프링 시큐리티에서는 AuthenticationSuccessHandler라는 인터페이스를 구현해서 설정할 수 있다.

로그아웃의 처리와 LogoutSuccessHandler

  • 로그인과 마찬가지로 특정한 URI를 지정하고, 로그아웃 처리 후 직접 로직을 처리할 수 있는 핸들러를 등록할 수 있다.
  • 로그아웃 시 세션을 무효화 시키는 설정이나 특정한 쿠키를 지우는 작업을 지정할 수 있다.
  • POST 방식으로 처리되는 로그아웃은 스프링 시큐리티의 내부에서 동작한다. 만일 로그아웃 시 추가적인 작업을 해야 한다면 logoutSuccessHandler를 정의해서 처리한다.
 

🌼JDBC를 이용하는 간편 인증/권한 처리

💡핵심 키워드

  • 인증과 권한에 대한 처리는 크게 보면 Authentication Manager를 통해서 이루어지는데 이때 인증이나 권한 정보를 제공하는 존재(Provider)가 필요하고, 다시 이를 위해서 UserDetailsService라는 인터페이스를 구현한 존재를 활용하게 된다.
  • jdbc-user-service는 기본적으로 DataSource가 필요하므로 root-context.xml에 있는 설정을 추가한다.

기존의 테이블을 이용하는 경우

  • 스프링 시큐리티가 기본적으로 이용하는 테이블 구조를 그대로 생성해서 사용하는 방식도 있지만, 기존의 회원 관련 데이터베이스가 구축되어 있었다면 이를 사용하는 것은 오히려 복잡해질 수 있다.
  • <security:jdbc-user-service> 태그에는 users-by-username-query 속성과 authorities-by-user-name-query 속성에 적당한 쿼리문을 지정해 주면 JDBC를 이용하는 설정을 그대로 사용할 수 있다.

BCryptPasswordEncoder 클래스를 이용한 패스워드 보호

  • bcrypt는 태생 자체가 패스워드를 저장하는 용도로 설계된 해시 함수로 특정 문자열을 암호화하고, 체크하는 쪽에서는 암호화된 패스워드가 가능한 패스워드인지만 확인하고 다시 원문으로 되돌리지는 못한다.
  • BCryptPasswordEncoder는 이미 스프링 시큐리티의 API 안에 포함되어 있으므로, 이를 활용해서 security-context.xml에 설정한다.
    • PasswordEncoder는 이미 스프링 시큐리티에서 제공하므로 이를 빈으로 추가하고, org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder로 지정한다.
  • 지정된 방식이 아닌 테이블 구조를 이용하는 경우에는 인증을 하는데 필요한 쿼리(users-by-username-query 속성값)와 권한을 확인하는데 필요한 쿼리(authorities-by-username-query 속성값)를 이용해서 처리한다.
 

🌼커스텀 UserDetailsService 활용

💡핵심 키워드

  • JDBC를 이용하는 방식은 사용자의 여러 정보들 중에서 제한적인 내용만을 이용한다는 단점이 있다.
  • 이러한 문제를 해결하기 위해서는 직접 UserDetailsService를 구현하는 방식을 이용하는 것이 좋다. 이를 이용하면 원하는 객체를 인증과 권한 체크에 사용할 수 있다.
  • 가장 일반적으로 많이 사용되는 방법은 여러 하위클래스들 중에서 org.springframework.security.core.userdetails.User 클래스를 상속하는 형태다.

회원 도메인, 회원 Mapper 설계

  • MyBatis에서는 하나의 결과에 부가적으로 여러 개의 데이터를 처리하는 경우 1:N의 결과를 처리할 수 있는 <resultMap>태그를 지원한다.
    • <resultMap><result><collection>을 이용해서 바깥쪽 객체와 안쪽 객체들을 구성할 수 있다.

CustomUserDetailsService 구성

  • 스프링 시큐리티의 UserDetailsService를 구현하는 클래스를 직접 작성할 경우, MemberMapper 타입의 인스턴스를 주입받아서 실제 기능을 구현한다.
    • 해당 클래스는 security-context.xml을 이용해서 스프링의 빈으로 등록한다.
  • 스프링 시큐리티의 UserDetailsService는 loadUserByUsername()라는 하나의 추상 메서드만을 가지고 있으며 리턴 타입은 org.springframework.security.core.userdetails.UserDetails라는 타입이다.
  • org.springframework.security.core.userdetails.User 클래스를 상속한다면 부모 클래스의 생성자를 호출해야만 정상적인 객체를 생성할 수 있다.
    • 이 과정에서 VO 인스턴스는 GrandAuthority 객체로 변환해야 하므로 stream()map()을 이용해서 처리한다.
 

🌼스프링 시큐리티를 JSP에서 사용하기

💡핵심 키워드

  • 별도의 인증/퀀한 체크를 하는 가장 큰 이유는 JSP 등에서 단순히 사용자의 아이디(스프링 시큐리티에서는 username) 정도가 아닌 사용자의 이름이나 이메일과 같은 추가적인 정보를 이용하기 위해서다.

JSP에서 로그인한 사용자 정보 보여주기

  • 권한이 적당하지 않으면 보여줄 수 없는 페이지의 경우 로그인한 사용자가 접근했을 때에는 해당 사용자의 여러 정보를 보여줄 필요가 있다.
  • 스프링 시쿠리티와 관련된 정보를 출력하거나 사용하려면 JSP 상단에 스프링 시큐리티 관련 태그 라이브러리의 사용을 선언하고, <sec:authentication> 태그와 principal이라는 이름의 속성을 사용한다.
  • <sec:authentication property="principal"/> 가 의미하는 것은 UserDetailsService에서 반환된 객체다. 즉 CustomUserDetailsService를 이용했다면 loadUserByUsername()에서 반환된 CustomUser 객체가 된다.
    • 이 사실을 이해하면 principal 이 CustomUser를 의미하므로 principal.member는 CustomUser 객체의 getMember()를 호출한다는 것을 알 수 있다.

표현식을 이용하는 동적 화면 구성

  • 경우에 따라서 특정한 페이지에서 로그인한 사용자의 경우에는 특정한 내용을 보여주고 그렇지 않은 경우에는 다른 내용을 보여주는 경우가 있다.
  • 이 때 유용한 것이 스프링 시큐리티의 표현식이다.
    • 표현식
      설명
      hasRole([role]) hasAuthority([authority])
      해당 권한이 있으면 true
      hasAnyRole([role, role2]) hasAnyAuthority([authority])
      여러 권한들 중에서 하나라도 해당하는 권한이 있으면 true
      principal
      현재 사용자 정보를 의미
      permitAll
      모든 사용자에게 허용
      denyAll
      모든 사용자에게 거부
      isAnonymous()
      익명의 사용자의 경우(로그인을 하지 않은 경우도 해당)
      isAuthenticated()
      인증된 사용자라면 true
      isFullyAuthenticated()
      Remember-me로 인증된 것이 아닌 인증된 사용자의 경우 true
 

🌼자동 로그인(remember-me)

💡핵심 키워드

  • 최근의 웹페이지들은 자동 로그인이나 로그인 기옥하기라는 이름으로 한 번 로그인하면 일정 시간 동안 다시 로그인을 하지 않아도 되는 기능을 가지고 있다.
  • remember-me기능은 거의 대부분 쿠키를 이용해서 구현된다.
  • 스프링 시큐리티의 경우 remember-me 기능을 메모리상에서 처리하거나, 데이터베이스를 이용하는 형태로 약간의 설정만으로 구현이 가능하다.
  • security-context.xml에는 <security:remember-me> 태그를 이용해서 기능을 구현한다. <security:remember-me> 태그는 다음과 같은 속성들을 지정할 수 있다.
    • key: 쿠키에 사용되는 값을 암호화하기 위한 키(key)값
    • data-source-ref: DataSource를 지정하고 테이블을 이용해서 기존 로그인 정보를 기록(옵션)
    • remember-me-cookie: 브라우저에 보관되는 쿠키의 이름을 지정한다. 기본값은 remember-me 이다.
    • remember-me-parameter: 웹 화면에서 로그인할 때 remember-me는 대부분 체크박스를 이용해서 처리한다. 이 때 체크박스 태그는 name 속성을 의미한다.
    • token-validity-seconds: 쿠키의 유효시간을 지정한다.

데이터베이스를 이용하는 자동 로그인

  • 자동 로그인 기능을 처리하는 방식 중에서 가장 많이 사용되는 방식은 로그인이 되었던 정보를 데이터베이스를 이용해서 기록해 두었다가 사용자의 재방문 시 세션에 정보가 없으면 데이터베이스를 조회해서 사용하는 방식이다.
  • 스프링 시큐리티의 공식 문서에 나오는 로그인 정보를 유지하는 테이블은 아래와 같은 스크립트로 구성된다.
    • create table persistent_logins( username varchar2(64) not null, series varchar2(64) primary key, token varchar2(64) not null, last_used timestamp not null );
  • 자동 로그인 기능을 이용하는 경우에 사용자가 로그아웃을 하면 기존과 달리 자동 로그인에 사용하는 쿠키도 삭제해 주도록 쿠키를 삭제하는 항목을 security-context.xml에 지정한다.
 

🌼어노테이션을 이용하는 스프링 시큐리티 설정

💡핵심 키워드

  • 스프링 시큐리티 역시 다른 기능들처럼 어노테이션을 이용해서 필요한 설정을 추가할 수 있다.
    • @Secured: 스프링 시큐리티 초기부터 사용되었고, () 안에 ROLE_ADMIN과 같은 문자열 혹은 문자열 배열을 이용한다.
    • @PreAuthorize, @PostAuthorize: 3버전부터 지원되며, () 안에 표현식을 사용할 수 있으므로 최근에는 더 많이 사용된다.
  • 컨트롤러에 사용하는 스프링 시큐리티의 어노테이션을 활성화하기 위해서는 security-context.xml이 아닌 스프링 MVC의 설정을 담당하는 servlet-context.xml에 관련 설정을 추가해야 한다.
    • security 네임스페이스를 이용해서 global-method-security를 지정한다.
    • 어노테이션은 기본으로 disabled 되어 있으므로 enabled로 설정한다.
 

🌼기존 프로젝트에 스프링 시큐리티 접목하기

💡핵심 키워드

로그인 페이지 처리

  • custumLogin.jsp를 작성할 때 신경 써야 하는 부분은 다음과 같다.
    • JSTL이나 스프링 시큐리티의 태그를 사용할 수 있도록 선언
    • CSS 파일이나 JS 파일의 링크는 절대 경로를 쓰도록 수정
    • <form> 태그 내의 <input> 태그의 name 속성을 스프링 시큐리티에 맞게 수정
    • CSRF 토큰 항목 추가
    • JavaScript를 통한 로그인 전송
  • 스프링 시큐리티는 기본적으로 로그인 후 처리를 SavedRequestAwareAuthenticationSuccessHandler라는 클래스를 이용한다.
    • SavedRequestAwareAuthenticationSuccessHandler를 이용하는 설정은 기존에 XML이나 Java 설정에서 authentication-success-handler-ref 속성이나 successHandler() 메서드를 삭제하고 관련 스프링 빈의 설정도 사용하지 않도록 한다.

게시물 작성 시 스프링 시큐리티 처리

  • 일반적인 경우라면 게시물 리스트의 경우 사용자들의 관심을 끌기 위해서 아무 제약 없이 보여주지만, 게시물 작성 시엔 로그인한 사용자에 한해서 처리되는 경우가 많다.
    • 이를 고려해 servlet-context.xml에는 스프링 시큐리티 관련 설정을 추가하고, Controller는 어노테이션을 통해서 제어하도록 한다.
  • @PreAuthroize를 이용할 때의 표현식은 isAuthenticated()로 어떠한 사용자든 로그인이 성공한 사용자만이 해당 기능을 사용할 수 있도록 처리한다.
  • View와 같이 스프링 시큐리티의 영향을 받는 JSP 파일에는 반드시 시큐리티 관련 태그 라이브러리를 설정하도록 주의한다.
  • 스프링 시큐리티를 사용할 때 POST 방식의 전송은 반드시 CSRF 토큰을 사용하도록 추가해야만 한다.
    • <form> 태그 내에 CSRF 토큰의 값을 <input type=’hidden’>으로 추가한다.
  • 게시물의 등록에서 주의할 점은 스프링 시큐리티의 적용 이후에 한글이 꺠지는 문제가 발생할 수 있다는 점이다.
    • 한글 처리는 web.xml을 이용해서 스프링의 CharacterEncodingFilter를 이용해서 처리하지만, 시큐리티를 필터로 적용할 때에는 필터의 순서가 인코딩 필터보다 뒤로 적용되게 주의해서 설정해야 한다.

게시물 조회와 로그인 처리

  • <secu:authentication> 태그를 매번 이용하는 것은 불편하기 때문에 로그인과 관련된 정보인 principal은 아예 JSP 내에서 pinfo라는 이름의 변수로 사용하도록 한다.
  • <sec:authorize>는 인증받은 사용자만이 영향을 받기 위해서 지정한다.

게시물의 수정/삭제

  • 게시물의 수정과 삭제는 브라우저에서는 로그인한 사용자만이 접근할 수 있지만, 사용자가 URL을 조작해서도 접근이 가능하기 떄문에 화면과 POST 방식으로 처리되는 부분에서 CSRF 토큰과 스프링 시큐리티를 적용한다.
  • 게시물의 수정과 삭제는 현재 로그인한 사용자와 게시물의 작성자가 동일한 경우에만 할 수 있다.

Ajax와 스프링 시큐리티 처리

  • <form> 태그를 이용하는 방식 외에 많이 사용되는 Ajax를 이용하는 경우에는 추가적인 설정이 필요하다.
  • POST, PUT, PATCH, DELETE와 같은 방식으로 데이터를 전송하는 경우에는 반드시 추가적으로 X-CSRF-TOKEN과 같은 헤더 정보를 추가해서 CSRF 토큰값을 전달하도록 해야 한다.
  • Ajax는 JavaScript를 이용하기 때문에 브라우저에서는 CSRF 토큰과 관련된 값을 변수로 선언하고, 전송 시 포함시켜 주는 방식으로 수정한다.
  • 기존 코드에서 csrfHeaderNamecsrfTokenValue 변수를 추가한다. CSRF 토큰의 값은 세션이 달라질 때마다 변한다.
    • 첨부파일의 등록과 삭제의 경우에도 POST 방식으로 동작하므로 CSRF 토큰의 처리가 필요하다.
    • 게시물의 수정 화면에서도 첨부파일은 추가되거나 삭제가 가능하므로 코드를 수정한다.
  • 댓글의 경우 모든 동작이 Ajax를 통해서 이루어지기 때문에 화면과 서버쪽에 수정을 한다.
    • 서버쪽에서는 Controller가 댓글에 대한 보안 원칙을 다음과 같이 설계할 수 있다.
      • 댓글의 등록: 로그인한 사용자만이 댓글을 추가할 수 있도록 한다.
      • 댓글의 수정과 삭제: 기존의 댓글 삭제에는 댓글 번호만으로 처리했는데, 서버 쪽에서 사용할 것이므로 댓글 작성자를 같이 전송하도록 수정한다.
    • 브라우저 쪽에서 달라지는 부분은 다음과 같다.
      • 댓글의 등록: CSRF 토큰을 같이 전송하도록 수정한다.
      • 댓글의 수정/삭제: 기존의 댓글 삭제에는 댓글 번호만으로 처리했는데, 서버 쪽에서 사용할 것이므로 댓글 작성자를 같이 전송하도록 수정한다.
  • CSRF 토큰에 대한 처리는 csrfHeaderName, csrfTokenValue 변수를 선언해서 처리한다.
  • jQuery를 이용해서 Ajax로 CSRF 토큰을 전송하는 방식은 첨부파일의 경우 beforeSend를 통해서 처리했지만, 기본 설정으로 지정해서 사용하는 것이 더 편하다.
  • aJaxSend()를 이용한 코드는 모든 Ajax 전송 시 CSRF 토큰을 같이 전송하도록 세팅되기 때문에 매번 Ajax 사용 시 beforeSend를 호출해야 하는 번거로움을 줄일 수 있다.
 

🏁결론

해당 내용을 정리하면서 Interceptor를 이용한 사용자의 권한이나 등급에 기반을 두는 로그인 체크를 연습해볼 수 있었다. 또한 스프링 웹 시큐리티를 통해 로그인 처리와 CSRF 토큰 처리, 암호화 처리, 자동로그인, JSP에서의 로그인 처리와 같은 개념을 이해할 수 있었다.
 
Share article

More articles

See more posts

👨🏻‍💻DriedPollack's Blog