반응형

 

트러블

클라이언트쪽에서 원래 보내던 데이터에 추가해서 객체가들어간 배열을 보내줄일이 생겼다.

처음에는 formData에 배열을 바로 직렬화해서 보냈는데 서버에서 데이터를 배열로받을수가없었다.

javaScript코드

const getIngredientsData = () => {
    const ingredientData = [];
    Array.from(ingredientRows.children).forEach((row) => {
        const ingredientName = row.children[0].value;
        const volume = row.children[1].value;
        const unit = row.children[2].value;

        ingredientData.push({
            name: ingredientName,
            volume: volume,
            unit: unit
        });
    });
    return ingredientData;
} //배열을만들고 요소를모아서 객체를만들어 배열에 push
// 만든 배열을 return해주는 함수

formData.append(`ingredients`, JSON.stringify(ingredientData[i]));

 

이렇게 클라이언트에서 보내주면 서버에서는 ingredients 필드에서 받아야하는데 

@Data
@Builder
public class CockTailRequest {
    private List<CocktailIngredientRequest> ingredients;
}

@Data
public class  CocktailIngredientRequest {
    private String name;
    private Integer volume;
    private String unit;
}

에러가 발생한다 String을 List타입으로 컨버트할수없다는에러가..

 

해결

그래서 찾아보니 formData에서 배열을 보내는방법이있었다. 같은필드이름에 인덱스를 달아서 보내주면 

자동으로 배열로 넘어간다고 한다.

그래서 클라이언트쪽 코드를 변경해줬다. 

const getIngredientsData = () => {
    const ingredientData = [];
    Array.from(ingredientRows.children).forEach((row) => {
        const ingredientName = row.children[0].value;
        const volume = row.children[1].value;
        const unit = row.children[2].value;

        ingredientData.push({
            name: ingredientName,
            volume: volume,
            unit: unit
        });
    });
    return ingredientData;
} //배열을만들고 요소를모아서 객체를만들어 배열에 push
// 만든 배열을 return해주는 함수

const ingredientData = getIngredientsData();
    for(let i=0;i<ingredientData.length;i++){
        formData.append(`ingredients[${i}]`, JSON.stringify(ingredientData[i]));
    }

배열을 반복문으로 돌려서 필드에 인덱스를 붙여주는 방법으로 보내도록 변경했다.

 

문제는 이렇게 해도 서버에서 List<String> 형태로는받아지는데

배열 내부의 객체인 CocktailIngredientRequest로는 컨버팅이 되지않았다.

이것저것 시도해보다가 시간만 너무날려서 문자열 배열로 받고 내부에서 컨버팅해줄수있게 함수를 추가해줬다.

@Builder
@Data
@Getter
    private List<String> ingredients;
   
   public List<CocktailIngredientRequest> getListIngredients() {
        ObjectMapper objectMapper = new ObjectMapper();

        return this.ingredients.stream().map(json -> {
            try {
                return objectMapper.readValue(json, CocktailIngredientRequest.class);
            } catch (JsonProcessingException e) {
                throw new RuntimeException("Error parsing JSON: " + e.getMessage());
            }
        }).collect(Collectors.toList());
    }
}

ingredients 배열내에는 Json문자열이있고 해당 Json문자열을 objectmapper로 convert해주도록 메서드를 만들었다.

 

트러블슈팅은못한것같고 트러블 회피정도는 되는듯한 해결방법이다. 

추후에 더 알아보면 클라이언트에서 객체배열을 보내고 서버에서 한번에 컨버팅해서 받는방법을 알아낼수있겠지 그때되면 이부분은 업데이트 해보겠다.

반응형
반응형

 

Spring Security 

스프링 시큐리티는 간단하게 인증과 인가, 즉 로그인할수있는 기능을 제공해주는 라이브러리이다.

스프링 시큐리티를 이용해 만들어져있는기능으로 로그인, 권한에 따른 접근 을 간편하게 구현할수있다.

 

https://velog.io/@hope0206/Spring-Security-%EA%B5%AC%EC%A1%B0-%ED%9D%90%EB%A6%84-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%97%AD%ED%95%A0-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0

스프링 시큐리티의 동작에대해 잘 설명되어있는 글이다. 여기에 더해서 jwt토큰을 더해 클라이언트에서 사용할 로그인 기능을 구현할 생각이다. 

 

 JWT(json web token)

jwt토큰을 이용하면 필요한 정보를 암호화해서 보관하고 가져와서 사용할수있다.

 

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}

 

jpa , security , swagger , jwt 를 사용하기위한 라이브러리들을 불러와서 사용한다.

 

SecurityConfig

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

    http
            // CORS(Cross-Origin Resource Sharing) 설정
            .cors((cors) -> cors.configurationSource(new CorsConfigurationSource() {
                @Override
                public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
                    CorsConfiguration corsConfiguration = new CorsConfiguration();
                    corsConfiguration.setAllowedOrigins(Collections.singletonList("*")); // 모든 출처 허용
                    corsConfiguration.setAllowedMethods(Collections.singletonList("*")); // 모든 HTTP 메서드 허용
                    corsConfiguration.setAllowCredentials(true); // 자격 증명 허용
                    corsConfiguration.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type")); // 허용된 헤더
                    corsConfiguration.setExposedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type")); // 노출된 헤더
                    return corsConfiguration;
                }
            }))
            // CSRF 토큰 비활성화
            .csrf((auth) -> auth.disable())
            // 폼 로그인 비활성화
            .formLogin((auth) -> auth.disable())
            // HTTP 기본 인증 비활성화
            .httpBasic((auth) -> auth.disable())
            // 권한 설정
            .authorizeHttpRequests((auth) -> auth
                    .requestMatchers("/login", "/", "/join").permitAll() // 로그인, 홈, 회원가입 페이지 접근 허용
                    .requestMatchers("/imgs/**").permitAll() // 이미지 파일 접근 허용
                    .requestMatchers("/admin/**","/swagger-ui/**").hasRole("ADMIN") // 관리자 권한 필요
                    .requestMatchers("/user/**").hasRole("USER") // 사용자 권한 필요
                    .anyRequest().authenticated() // 나머지 요청은 인증 필요
            )
            // 세션 관리 설정
            .sessionManagement((session) -> session
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 무상태 세션 정책
            )
            // JWT 필터 추가
            .addFilterBefore(new JwtFilter(jwtUtil), LoginFilter.class)
            // 로그인 필터 추가
            .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil),
                    UsernamePasswordAuthenticationFilter.class)
    ;

    return http.build();
}

 

jwt 인증방식의 로그인을 사용하기위해서 httpBasic ,sessionManagement 는 비활성화 해주어야한다.

기존 http인증방식이아닌 jwt로 인증을 해주어야하기 때문이다. 

 

CustomUserDetails

@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final UserEntity userEntity;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(
                new GrantedAuthority() {
                    @Override
                    public String getAuthority() {
                        return String.valueOf(userEntity.getRole());
                    }
                }
        );
        return collection;
    }

    @Override
    public String getPassword() {
        return userEntity.getPassword();
    }

    @Override
    public String getUsername() {
        return userEntity.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;
    }
}

 

CustomUserDetailService

@Service
@RequiredArgsConstructor
public static class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity byEmail = userRepository.findByEmail(username).orElseThrow(IllegalArgumentException::new);
        return new CustomUserDetails(byEmail);
    }
}

 

사용자가 로그인폼에 아이디와 비밀번호를 입력하고 로그인을하게되면

  1. UsernamePasswordAuthenticationToken 객체가 생성
  2. AuthenticationManager가 이 토큰을 받아 인증을 시도
  3. AuthenticationManager는 내부적으로 AuthenticationProvider를 호출하여 인증 로직을 수행
  4. AuthenticationProvider는 CustomUserDetailsService의 loadUserByUsername 메서드를 호출하여 사용자 정보를 조회
  5. loadUserByUsername메서드 에서는 사용자 이메일로 UserEntity를 찾아 CustomUserDetails 객체를 생성합니다.
  6. CustomUserDetails 객체에는 사용자의 이메일, 비밀번호, 권한 등의 정보가 포함
  7. AuthenticationProvider는 CustomUserDetails 객체에서 가져온 비밀번호와 사용자가 입력한 비밀번호를 비교하여 인증 여부를 판단 인증이 성공하면 인증된 Authentication객체를 반환. 실패하면 Exception을 던진다.

이제 인증을 한후 인증정보를 jwt토큰으로 만들어 클라이언트에 보내주어야한다.

 

JwtUtil

@Component
public class JwtUtil {
    private SecretKey secretKey;
    private String EMAIL = "email";
    private String ROLE = "role";

    public JwtUtil(@Value("${spring.jwt.secret}")String secret) {
        this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), SIG.HS256.key().build().getAlgorithm());
    }

    public String getUsername(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get(EMAIL, String.class);
    }

    public String getRole(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get(ROLE, String.class);
    }

    public Boolean isExpired(String token) {
        JwtParser parser = Jwts.parser().verifyWith(secretKey).build();
        Jws<Claims> claimsJws = parser.parseSignedClaims(token);
        Claims payload = claimsJws.getPayload();
        Date expiration = payload.getExpiration();
        boolean before = expiration.before(new Date());
        return before;
    }

    public String createJwt(String username, String role, Long expiredMs) {
        long now = System.currentTimeMillis();
        Date expiration = new Date(now + expiredMs);
        return Jwts.builder()
                .claim(EMAIL, username)
                .claim(ROLE, role)
                .issuedAt(new Date(now))
                .expiration(expiration)
                .signWith(secretKey)
                .compact();
    }
}

jwt 토큰을 만들고 읽을 수있는 util 클래스를 우선생성해준다.

 

LoginFilter - 로그인 시 동작할 필터

@RequiredArgsConstructor
@Slf4j
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JwtUtil jwtUtil;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        // 1. 클라이언트가 보낸 username과 password를 추출합니다.
        String userName = obtainUsername(request);
        String password = obtainPassword(request);

        // 2. 추출한 username과 password로 UsernamePasswordAuthenticationToken을 생성합니다.
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userName, password, null);

        // 3. AuthenticationManager에게 인증을 요청합니다.
        return authenticationManager.authenticate(authenticationToken);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
                                            Authentication authResult) throws IOException, ServletException {
        // 4. 인증이 성공하면 CustomUserDetails 객체를 가져옵니다.
        CustomUserDetails customUserDetails = (CustomUserDetails) authResult.getPrincipal();

        // 5. CustomUserDetails에서 username과 role을 추출합니다.
        String username = customUserDetails.getUsername();
        String role = getRole(customUserDetails);

        // 6. JwtUtil을 사용하여 JWT 토큰을 생성합니다.
        String token = jwtUtil.createJwt(username, role, 60 * 60 * 1000L);

        // 7. 생성한 JWT 토큰을 응답 헤더에 추가합니다.
        response.addHeader("Authorization", "Bearer " + token);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                              AuthenticationException failed) throws IOException, ServletException {
        // 8. 인증이 실패하면 응답 상태 코드를 401(Unauthorized)로 설정합니다.
        response.setStatus(401);
    }

    private String getRole(CustomUserDetails customUserDetails) {
        // 9. CustomUserDetails에서 사용자의 권한(role)을 추출합니다.
        return customUserDetails.getAuthorities().iterator().next().getAuthority();
    }
}

 

로그인 할경우 해당 필터를 통과해 attemptAuthentication메서드로 인증을 시도하고 인증을 성공하는경우 

successfulAuthentication메서드를 실행하고 jwt토큰을 발급해서 응답헤더에 토큰을 붙여서 응답을 보내준다.

실패하는경우에는 unsuccessfulAuthentication을 실행시키고 응답에 401에러를 보내주게된다.

 

이제 이렇게하면 로그인을 하고 응답으로 jwt토큰을 받아서 클라이언트에 저장할수있다.

 

이제 다시 서버에서 jwt토큰을 읽어서 해당 유저의 인증된 정보를 받아서 보관하는 필터를 만들어줘야한다.

 

JwtFilter

@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

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

        // 1. 요청 헤더에서 Authorization 헤더를 가져옵니다.
        String authorization = request.getHeader("Authorization");

        // 2. Authorization 헤더가 없거나 "Bearer" 로 시작하지 않으면 토큰이 없는 것으로 간주하고 다음 필터로 넘어갑니다.
        if (authorization == null || !authorization.startsWith("Bearer")) {
            log.error("token null");
            filterChain.doFilter(request, response);
            return;
        }

        // 3. Authorization 헤더에서 실제 토큰 값을 추출합니다.
        String token = authorization.split(" ")[1];

        // 4. 토큰의 만료 시간을 확인합니다. 만료된 경우 다음 필터로 넘어갑니다.
        if (jwtUtil.isExpired(token)) {
            log.error("token expired");
            filterChain.doFilter(request, response);
            return;
        }

        // 5. 토큰이 유효한 경우 setAuthentication 메서드를 호출하여 인증 정보를 SecurityContextHolder에 설정합니다.
        setAuthentication(token);

        // 6. 다음 필터로 요청을 전달합니다.
        filterChain.doFilter(request, response);
    }

    private void setAuthentication(String token) {
        // 7. 토큰에서 사용자 이름과 역할 정보를 추출합니다.
        String username = jwtUtil.getUsername(token);
        String role = jwtUtil.getRole(token);

        // 8. 추출한 정보로 UserEntity 객체를 생성합니다.
        UserEntity user = UserEntity.builder()
                .email(username)
                .password("")
                .role(UserRole.valueOf(role))
                .build();

        // 9. UserEntity 객체를 이용하여 CustomUserDetails 객체를 생성합니다.
        CustomUserDetails customUserDetails = new CustomUserDetails(user);

        // 10. CustomUserDetails 객체와 권한 정보를 이용하여 UsernamePasswordAuthenticationToken 객체를 생성합니다.
        Authentication authenticationToken = new UsernamePasswordAuthenticationToken(
                customUserDetails, null, customUserDetails.getAuthorities());

        // 11. 생성한 인증 토큰을 SecurityContextHolder에 설정합니다.
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    }
}

 

서버에 api요청을 하는경우 이 필터를 통과해서 토큰이 있는경우 setAuthentication으로 SecurityContextHolder에 인증된 값을 세팅해준다. 이러면 컨트롤러에서도 인증된 유저의 정보를 사용할수도있고 인증되지않은 유저, 토큰을 가지지않은 유저가 api에 접근할때에도 통과시키지않을수있다.

 

보통 클라이언트와 서버를 나눠서 사용할때는 jwt토큰방식을 사용하고 

서버에서 프론트까지 모두 만드는경우에는 세션방식을 많이 이용한다고 한다.

 

jwt토큰방식은 stateless하다는것이 장점이라고하는데 토큰을 보내주고난다음에는 서버에서는 토큰을 보관하고있지않고 요청이 올때마다 토큰을 읽어서 인증하기때문에 서버에 부담이 적다고 한다.

 

공부를 더 해서 잘 이해하게되면 나중에 이 글도 버전업으로 더 잘써보겠다.

반응형

'공부 임시 저장소' 카테고리의 다른 글

도메인 레코드란? DNS Record  (1) 2024.10.13
도메인이란? DNS  (1) 2024.10.13
스프링부트 - aws s3파일저장  (0) 2024.06.25
스프링부트 - 파일 저장  (0) 2024.06.24
채팅앱 - 채팅 미전송 메시지 처리  (0) 2024.06.24
반응형

기존에 로컬에 이미지를 저장하던 형식에서 클라우드인 aws 의 s3에 올리는 방법으로 변경했다.

s3는 aws에서 지원해주는 파일을 저장해주는 서비스인데 간단하게 네이버박스나 구글드라이브 정도를 생각하면 될것같다. 

 

s3 버켓 생성 

aws의 s3를 이용하려면 s3버켓을 생성해주어야한다.  

aws에 접속해서 s3를 검색하고 페이지에서 버킷 만들기를 선택한다.

여기서 버켓의 이름을 만들고 옵션은 모두 기본으로 설정해주었다. 

버켓의 이름은 유일해야하는 조건이 있어서 흔한 mybucket같은 이름은 지정할수 없었다.

 

버켓을 생성하고나면 버켓에 접근할 권한이 필요하다.

IAM으로 들어가서 s3에 접근할 사용자를 생성해준다.

 

사용자 이름을 설정하고 권한에 AmazonS3FullAccess를 설정해준다.

이후 해당 사용자로 들어가서 액세스키를 만들면 s3에 접근할 키 생성도 끝난다. 

액세스 키를 만들고나서는 추후에 다시 볼수없기때문에  파일로 저장하는걸 추천한다.

 

이제 스프링 부트에서 s3를 사용하기위한 설정을 해보자.

 

build.gradle 에 라이브러리 추가

implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

 

 

application.yml

cloud:
  aws:
    s3:
      bucket: "버킷이름"
    stack.auto: false
    region.static: ap-northeast-2
    credentials:
      accessKey: "${S3_ACCESS_KEY}"
      secretKey: "${S3_SECRET_KEY}"

 

만약 버킷안에 폴더를 만들고 그안에 저장하고싶다면 버킷이름/폴더이름 으로 접근하면 된다.

 

S3Config

@Configuration
public class S3Config {
    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;
    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;
    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    public AmazonS3Client amazonS3Client(){
        return (AmazonS3Client) AmazonS3ClientBuilder.standard()
                .withCredentials(
                        new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKey,secretKey))
                )
                .withRegion(region)
                .build();
    }
}

 이렇게 S3Config까지 설정해주면 AmazonS3 클래스로 s3에 접근이 가능해진다.

 

S3UploadService

@Service
@RequiredArgsConstructor
public class S3UploadService {

    private final AmazonS3 amazonS3;
    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    public void saveFile(MultipartFile multipartFile, String userName) throws IOException {
        String fileName = createFileName(multipartFile);
        ObjectMetadata metadata = getObjectMetadata(multipartFile);
        amazonS3.putObject(bucket, fileName, multipartFile.getInputStream(), metadata);
    }

    public void deleteFile(String fileName) {
        amazonS3.deleteObject(bucket, fileName);
    }

    public FileEntity updateFile(String oldFileName, MultipartFile newFile, String uploader) throws IOException {
        //기존이미지를 삭제
        deleteFile(oldFileName);
        //새로운 이미지를 추가
        return saveFile(newFile, uploader);
    }
    
    public String getPath(String fileName){
    	return amazonS3.getUrl(bucket, fileName).toString();
    }

    private static ObjectMetadata getObjectMetadata(MultipartFile multipartFile) {
        ObjectMetadata objectMetadata = new ObjectMetadata();
        objectMetadata.setContentLength(multipartFile.getSize());
        objectMetadata.setContentType(multipartFile.getContentType());
        return objectMetadata;
    }
}

 

파일저장

파일을 저장하기위해서는 버켓이름,파일의이름,파일의 인풋스트림,메타데이터가 필요하다.

메타데이터는 파일의 크기와 컨텐츠의 타입이 들어가게된다. 

putObject로 파일을 저장하게되면 설정한 이름으로 파일이잘 저장되는걸 확인할수있다. 

 

파일삭제

삭제는 간단하게 어떤 버켓의 어떤 파일을 삭제할지에 대한 정보만 있으면된다. 

deleteObject에 버켓과 파일의 이름을 파라미터로 넣으면 해당하는 파일이 삭제된다.

 

이미지에 접근

파일을 저장하면 해당 파일이나 이미지에 접근을 해야한다.

나의 경우는 이미지를 올리고 페이지에서 띄워줘야하는데 어떤 경로로 접근해야할지 알수없었다.

getUrl 을 사용하면 해당 파일에 접근할수있는경로를 리턴해준다. 

이 경로로 바로 접근하려고하면 에러 페이지를 띄워준다.

이를 해결하기 위해서는 cloudFront로 s3이미지를 배포해서 접근하는 방법이 필요하다고 한다.

cloudFront를 생성할때 domain을 클릭하면 내가만든 s3버켓이 나온다 해당 s3버켓을 선택하고 

보안보호만 비활성화로 선택해준다음 나머지 옵션은 모두기본으로 두었다. 그후에 배포를 생성해준다.

 

이후에도  access denied가 뜰텐데 s3bucket의 정책이 접근권한이 aws사용자에게만 열려있기때문이다. 

접근하려는 s3 bucket으로 다시 이동하여 권한으로 들어가면 버킷정책이있다. 여기에서 옵션을 지정해주면된다.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AddPerm",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::버켓이름/*"
        }
    ]
}

 

나는 해당 버켓의 모든 폴더에 접근가능하도록 열었는데 특정폴더만 열어놓을수도있다.

이렇게 하면 getUrl로 얻어지는 주소로 저장한 파일에 접근이 가능하다.

 

https://s3.ap-northeast-2.amazonaws.com/cokeholics-bucket/coctail_library_imgs/18153d0d-63be-4bec-834b-8a54f4566dfb_martini.jpg

 

반응형

+ Recent posts