Coding History/project

구글 로그인과 로컬 로그인 세션 관리 (시큐리티를 통한)

BlackBirdIT 2024. 9. 12. 15:44

이제 동시에 여러 계정이 로그인 되는 것을 막을 필요가 있었다.

기존에 사용하던 rq가 정상 작동 하지 않는 것을 보고 알아보니까

이제는 security가 모든 것을 담당하게 되었다는 것을 알게되었다. 그래서 얘가 세션을 관리하게끔도 만들어줘야한다.

일단은 해당 정보를 알았으니, 기본 세션 설정 코드를 시큐리티 클래스에 추가해줬다.

                // 세션 관리 설정
                .sessionManagement(session -> session
                        .sessionFixation().migrateSession()  // 세션 고정 보호 (로그인 시 세션 새로 생성)
                        .maximumSessions(1)  // 동시에 로그인 가능한 세션 수 제한
                        .maxSessionsPreventsLogin(true)  // 기존 세션 유지, 새로운 로그인 시도 차단
                        .expiredUrl("/usr/member/login?expired=true")  // 만료된 세션의 리다이렉트 URL
                )

희희 잘 되겠지? 하고 이제 접속을 하니까..

또 무한 리디렉션 하네 어떻게 되먹은게 뭐 조금만 건들면 맨날 저러냐.

그래서 일단 설정을 지우고 다시 봐도 리디렉션을 계속 해서 오류가 생겼다.

302에러다.

오케이 일단 설정이 먹지 않는 것 같아서 시큐리티의 세션 정보를 따로 뽑아와서 기존에 있는 rq 클래스로 관리해보겠다, 는 생각에 도달했다.

일단은 수정한 rq 클래스.

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class Rq {

    @Getter
    private boolean isLogined;
    @Getter
    private int loginedMemberId;
    @Getter
    private Member loginedMember;

    private HttpServletRequest req;
    private HttpServletResponse resp;

    public Rq(HttpServletRequest req, HttpServletResponse resp, MemberService memberService) {
        this.req = req;
        this.resp = resp;
        isLogined = false;

        // 로그인 상태 확인
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null && authentication.isAuthenticated() && !authentication.getPrincipal().equals("anonymousUser")) {
            isLogined = true;
            loginedMember = memberService.getMemberByEmail(((UserDetails) authentication.getPrincipal()).getUsername());
            loginedMemberId = loginedMember.getId();
        } else {
            isLogined = false;
            loginedMemberId = 0;
            loginedMember = null;
        }

        this.req.setAttribute("rq", this);
    }

    // 세션에 로그인 정보 저장
    public void login(Member member) {
        HttpSession session = req.getSession();
        session.setAttribute("loginedMemberId", member.getId());
        session.setAttribute("loginedMemberEmail", member.getEmail());
        isLogined = true;
        loginedMemberId = member.getId();
        loginedMember = member;
    }

    // 로그아웃 처리
    public void logout() {
        HttpSession session = req.getSession(false);
        if (session != null) {
            session.invalidate();  // 세션 무효화
        }
        isLogined = false;
        loginedMemberId = 0;
        loginedMember = null;
        SecurityContextHolder.clearContext();  // Spring Security의 SecurityContext 초기화
    }

    public void initBeforeActionInterceptor() {
        System.err.println("initBeforeActionInterceptor 실행");

        // 현재 요청 경로를 세션에 저장할 수 있도록 설정
        String currentUri = getCurrentUri();
        req.setAttribute("currentUri", currentUri);
    }

    public void printHistoryBack(String msg) throws IOException {
        resp.setContentType("text/html; charset=UTF-8");
        println("<script>");
        if (!Ut.isEmpty(msg)) {
            println("alert('" + msg + "');");
        }
        println("history.back();");
        println("</script>");
    }

    private void println(String str) {
        print(str + "\n");
    }

    private void print(String str) {
        try {
            resp.getWriter().append(str);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public String historyBackOnView(String msg) {
        req.setAttribute("msg", msg);
        req.setAttribute("historyBack", true);
        return "usr/common/js";
    }

    public String getCurrentUri() {
        String currentUri = req.getRequestURI();
        String queryString = req.getQueryString();

        if (currentUri != null && queryString != null) {
            currentUri += "?" + queryString;
        }

        return currentUri;
    }
}

SecurityContextHolder가 핵심. 이걸로 시큐리티의 세션 정보를 가져와서 처리하는 것.

이걸 적용한 다음 로그인한 유저 정보를 보고 싶어서 login처리 로직도 json응답 형식으로 바꾸고 컨트롤러를 고치는 와중에 어떤 생각이 스쳤다.

"아 생각해보니까 컨트롤러를 안거치는데?"

그래서 시큐리티에서 인증을 받으면서 내 컨트롤러로 넘길 수 있는 방법을 찾아야했다.

그래서 일단

//로컬 로그인 직접 처리.
.formLogin(AbstractHttpConfigurer::disable)

로컬로그인은 직접 처리하기 위해서 비활성화 시켜줬다. 그리고 시큐리티 클래스 제일 아래에 AuthenticationManager 빈 설정을 해주고.

// AuthenticationManager 빈 설정
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Autowired
    private AuthenticationManager authenticationManager;

        //로컬 로그인 처리
    @PostMapping("/doLocalLogin")
    public ResultData<Member> doLocalLogin(@RequestParam String email, @RequestParam String loginPw) {
        try {
            // 인증 객체 생성
            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(email, loginPw);

            // 인증 요청 및 처리
            Authentication authentication = authenticationManager.authenticate(token);

            // 인증 성공 시, SecurityContext에 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);

            // 로그인된 사용자 정보 가져오기
            UserDetails userDetails = (UserDetails) authentication.getPrincipal();
            Member loginedMember = memberService.getMemberByEmail(userDetails.getUsername());

            // 세션에 로그인 정보 저장
            rq.login(loginedMember);

            return ResultData.from("S-1", "로그인 성공", "member", loginedMember);
        } catch (Exception e) {
            return ResultData.from("F-1", "로그인 실패: " + e.getMessage());
        }
    }

컨트롤러에 삭제했던 login 메서드를 만들어주자.

그럼 내가 설정한 resultData를 JSON 형식으로 처리할 수 있을 것이다!!

이젠 로그인 상태에서 로그인 페이지에 접근할 경우에 기존 만들어 두었던 알람이 뜨고 해당 페이지 접근을 막는다.

확실하게 되었는지 확인하기 위해서 디버그를 위한 코드를 컨트롤러에 작성해주고 또, rq를 사용해서 main페이지의 콘솔에서 유저 정보를 확인할 수 있게 했는데,

// 로컬 로그인 처리
    @PostMapping("/doLocalLogin")
    @ResponseBody
    public ResultData<Member> doLocalLogin(@RequestParam String email, @RequestParam String loginPw) {
        try {
            System.err.println("=============로그인 컨트롤러 작동.============");

            // 인증 객체 생성
            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(email, loginPw);

            // 인증 요청 및 처리
            Authentication authentication = authenticationManager.authenticate(token);

            // 인증 성공 시, SecurityContext에 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);

            // 로그인된 사용자 정보 가져오기
            UserDetails userDetails = (UserDetails) authentication.getPrincipal();
            System.err.println("=============유저 정보 가져오기 성공.============");
            System.err.println("유저 이름: " + userDetails.getUsername());

            Member loginedMember = memberService.getMemberByEmail(userDetails.getUsername());

            // 세션에 로그인 정보 저장
            rq.login(loginedMember);
            System.err.println("=============세션 저장 성공.============");

            return ResultData.from("S-1", "로그인 성공", "member", loginedMember);

        } catch (Exception e) {
            // 상세한 오류 메시지 및 스택 트레이스 출력
            e.printStackTrace();
            System.err.println("로그인 실패: " + e.getMessage());

            return ResultData.from("F-1", "로그인 실패: " + e.getMessage());
        }
    }

로그인이 안되넹?

Bad credentials 라는 에러같은데.
아 알아보니까 비밀번호가 틀린거구나.

비번 맞게하니까 이렇게 뜨네 근데 왜 예상치 못해.

JS 수정해야되나보다.

JS에서
if (typeof data === 'object' && data.resultCode && data.resultCode.startsWith('S-'))
이 부분의 resultCode가 R 대문자로 시작해서 이랬던 것 같다. 고치니까 메인으로 넘어간다.

콘솔에 찍힌 것 보니까 유저 정보는 정확하게 가져와서 세션도 유지되는 것 같다.


알람이 뜨는 이유가 MyWebMVCConfigurer에서 관리하는 인터셉터 때문이였다.

따라서 해당 인터셉터를 시큐리티로 옮겨서 사용이 가능할까 에 대한 의문이 들었다.

만들어 둔 기능 최대한 활용하는게 좋으니까 웬만하면 버리고 싶지 않았다.
그래서 이것저것 찾아봤는데 시큐리티에서도 내가 만든 인터셉터 처럼 인터셉터로 사용할 수 있는게 있었다.
그럼 이걸 써야되는게 맞잖아?
그래서 원래 내가 갖고 있던 인터셉터 과감하게 삭제하기로 결정.

그래서 우선한 것은 CustomFilter를 filter 패키지 아래에 생성해줬다.

@Component
public class CustomLoginFilter extends OncePerRequestFilter {

    @Autowired
    private Rq rq;

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

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getPrincipal())) {
            // Rq 클래스를 사용해 경고 메시지 출력
            rq.printHistoryBack("로그인 후 이용해주세요.");
            return;
        }

        filterChain.doFilter(request, response);
    }
}

같은 방식으로 로그 아웃도 만들어주고,

시큐리티에 전역으로 쓸 수 있게 선언해주었다.

    @Autowired
    private CustomLoginFilter customLoginFilter;

    @Autowired
    private CustomLogoutFilter customLogoutFilter;

     public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
     //기타 등등

     // 커스텀 필터 적용
                .addFilterBefore(socialLoginFilter, UsernamePasswordAuthenticationFilter.class)  // 소셜 로그인 필터 추가
                .addFilterBefore(customLoginFilter, UsernamePasswordAuthenticationFilter.class)  // 커스텀 로그인 필터 추가
                .addFilterBefore(customLogoutFilter, UsernamePasswordAuthenticationFilter.class)  // 커스텀 로그아웃 필터 추가

                .formLogin(AbstractHttpConfigurer::disable)  // 로컬 로그인 직접 처리

     // 기타 등등

     }

이렇게!

이제 잘 작동하는지 확인하면 세션관리가 제대로 되는지 확인할 수 있다.

로그인 시도를 해보니까

얼레 이상한걸 받아와서 잘 생각해보니까 제외 목록에 doLocalLogin을 넣지 않아서 form태그가 post할 때 필터에 막혀서 뜬 현상이라고 금방 생각해냈다.

그래서 조건문을 더 추가.

        // 로그인 페이지나 회원가입 페이지, 메인 페이지에 대해서는 필터링을 제외함
        if (requestURI.equals("/usr/member/login") || requestURI.equals("/usr/member/join") || requestURI.equals("/usr/home/main") || requestURI.equals("/usr/member/doLocalLogin")) {
            filterChain.doFilter(request, response);
            return;
        }

이렇게 더 빡쎄게 조건문 추가.

이젠 되지 않을까?

오케이 로그인은 다시 성공했고, 이제 로그인페이지에 로그인 상태로 접근해보자.

접근이... 되네????

로그인 상태 저장이 제대로 되지 않는 것 같다.

컨트롤러 접근도 잘하고 로직도 다 완료했다고 뜨는데 왜그러지?

메인에서 로그인 유저 정보 불러오게끔 코드를 추가해봤는데,

콘솔에 아무것도 찍히지 않았다. 어디서 누락되거나 세션유지가 안되는건데 어디가 문제인지 모르겠네.

우선은 시큐리티에서 설정한 쿠키가 유지되고 있는지 확인해보자.

쿠키는 확인된다. 새로고침한 이후에도 남아있음.

강제로 삭제하고 새로고침해도 생성됨,.

일단은 문제 파악을 정확히 해보자.

문제가 무엇인가?

로컬 로그인 로직 이후, 메인페이지로 넘어가는 것 까지는 됨, 콘솔에도 세션저장 성공이 뜨긴함.
이후 메인페이지에서 http://localhost:8081/usr/member/doLogout 을 실행시 로그인 후 이용하라는 알람이 뜸, 내가 커스텀한 로그아웃후 사용하라는 로직에 걸리는 것 = 로그인이 되지 않았다는 뜻.
그러니까 로그인 로직 자체가 지금 정상 작동을 하지 않는 것이거나 세션을 유지하지 못하는 것. 로직 자체에서 비밀번호를 틀리거나 할 때 알람은 정확히 뜨니까 컨트롤러 접근은 제대로 하고 검증도 제대로 하는 것 같음. 콘솔에 프린트도 찍히고. 그럼 세션 저장에 문제가 있는 것, 이 부분을 깊게 파봐야 할 것 같음.
근데 이게 미치겠는게, 쿠키는 남아있음(???)... 일단 이건 배제해두고 세션에 집중해보자.

우선은 문제 파악을 정확히 하기 위해서

    @RequestMapping("/usr/home/main")
    public String showMain(HttpServletRequest request) {
        // SecurityContextHolder에 저장된 인증 정보 확인
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null && authentication.isAuthenticated()) {
            System.out.println("로그인된 사용자: " + authentication.getName());
        } else {
            System.out.println("로그인된 사용자 없음");
        }

        // 세션에서 로그인된 사용자 정보 확인
        Member loginedMember = (Member) request.getSession().getAttribute("loginedMember");
        if (loginedMember != null) {
            System.out.println("세션에 저장된 사용자: " + loginedMember.getEmail());
        } else {
            System.out.println("세션에 저장된 사용자 없음");
        }

        return "usr/home/main";
    }

메인페이지에서 콘솔을 찍도록 해봤다.

예? 뜨는데??? 뭐지? 근데 생각해보니까 anonymousUser로 이름을 저장한 적은 없다.

이거 검색해보니까 인증되지 않은 사용자라네. anonymousUser가. 그래서 문제가 생긴 것 같다. 그럼 세션정보 저장이 아니고 시큐리티에서 인증을 받는 과정에서 생긴 문제라고 귀결된다.

그러니까 Spring Security의 인증 상태가 익명 사용자로 저장하고 있는 것이 문제라는 것.

GPT야 도와줘~

GPT 가라사대

문제 원인 분석:

  1. SecurityContextHolder가 올바르게 갱신되지 않음:
  • 로그인 성공 시 SecurityContextHolder.getContext().setAuthentication(authentication)을 통해 인증 정보를 SecurityContext에 저장해야 하는데, 이 정보가 제대로 갱신되지 않고 anonymousUser로 남아 있는 문제일 수 있습니다.
  1. 필터 체인에서 AnonymousAuthenticationFilter가 동작:
  • AnonymousAuthenticationFilter가 인증된 사용자임에도 불구하고 익명 사용자로 인증을 덮어쓰는 상황일 수 있습니다. 이 필터가 활성화된 상태에서 SecurityContext가 잘못 설정될 수 있습니다.

라고 하네.

그런데 거슬리던게 하나 있었는데,

이부분에 유저 이름을 이메일로 가져와서 인증을 제대로 못거친게 아닐까 라는 생각이 스쳤다.

            // 로그인된 사용자 정보 가져오기
            UserDetails userDetails = (UserDetails) authentication.getPrincipal();
            System.err.println("=============유저 정보 가져오기 성공.============");
            System.err.println("유저 이름: " + userDetails.getUsername());

            Member loginedMember = memberService.getMemberByEmail(userDetails.getUsername());

만든 코드도 name인데 말이지? 그래서 이걸 다시 가져가서 물어봤는데

UserDetails 라는 구현체가 존재하지 않아서 생긴 문제라고 한다. 확실히 UserDetailsService만 있고 난 인터페이스를 생성해준 기록도 기억도 없긴하다.

구현체 생성 후, 로그인 시도 하니까

=============유저 정보 가져오기 성공.============
유저 이름: test1
=============세션 저장 성공.============
이렇게 잘뜨네,

근데?

희희 이제 슬슬 욕하고 싶다.

어찌 됐든 간에 세션 문제가 맞다.

지금 상황은 둘 중 하나다, 인증 로직 자체가 제대로 안되거나, 아니면 아까 위에서 언급한 익명사용자 처리 로직 때문.

근데 로컬로그인은 이메일과 비밀번호만 검증하기 때문에 여기에 대한 문제는 적다. 왜? 애초에 틀리면 로그인 로직을 넘어갈 수가 없기 때문에. 그러니까 우리는 시큐리티에 집중해보자.

우선 지금 문제가 생긴 부분인 AnonymousAuthenticationFilter를 까보자.

이게 뭘 하는거냐면 이런거라고 한다.

AnonymousAuthenticationFilter의 기본 동작:

  • 익명 사용자 필터는 인증되지 않은 사용자가 요청할 때, 자동으로 익명 인증(anonymousUser)을 부여한다.
    기본적으로는 인증되지 않은 요청에만 적용된다. 그러나 어떤 이유로 인증된 사용자에게도 적용된다면, SecurityContext에 저장된 인증 정보가 덮어써지는 문제가 발생할 수 있다.

그러니까 지금 뭐가 문젠지는 모르겠는데 컨트롤러로 검증이 끝난 사용자를 익명으로 전환시켜버리고 있단 것이다.

그래서 시큐리티에서 해당 기능을 아예 꺼버릴 것이다.

// 익명 사용자 처리 비활성화
.anonymous(AbstractHttpConfigurer::disable)

한줄 추가해주면 된다.

이렇게 하면 인증이 필요한 곳이라면 아예 접근 자체를 못하게 막아버리게 된다. 근데 뭐 내 사이트에서 인증이 필요한 곳은 딱히 없을 것이기 때문에 상관없다. 이제 제대로 되는지 확인만 하면 된다.

아하 ! 로직 자체가 똥이였구나.

익명이여서 익명으로 처리한거였네 ㅋㅋㅋㅋㅋ 왜 안될까 컨트롤러에서는 잘 넘기고 어디서 정보를 잊어버리는건가.

SecurityContextHolder에서 인증 정보가 사라지는 문제라고 명시를 해주네 지피티가.

왜 사라지지?

지금 우리가 rq로 빼서 세션을 관리하고 있는데 시큐리티로 넘겨준 적이 없다. 그래서 발생한 문제인가?

rq의 로그인 메서드에서 인증 정보를 넘겨주지 않고 있었다.

    // 로그인 처리 시 호출 (세션에 사용자 정보 저장할 때 사용)
    public void login(Member member) {
        // 필요시 세션에 사용자 정보 추가
        req.getSession().setAttribute("loginedMember", member);
    }

// 수정.
// 로그인 처리 시 호출 (세션에 사용자 정보 저장할 때 사용)
public void login(Member member) {
    // 세션에 사용자 정보 저장
    req.getSession().setAttribute("loginedMember", member);

    // Spring Security의 SecurityContext에 인증 정보 저장
    Authentication authentication = new UsernamePasswordAuthenticationToken(member.getEmail(), member.getLoginPw(), new ArrayList<>());
    SecurityContextHolder.getContext().setAuthentication(authentication);
}

이후 로그인 시도.

뭔가 달라졌다. 해결될 조짐이 보인다.

로직엔 문제 없어보여서 서버 재시작 후 테스트.

세션에 저장된 사용자를 불러오는건 성공했는데, 로그인된 사용자는 없다고? 뭔소리야 이건 또

로그를 다 찍어보니까

=============로그인 컨트롤러 작동.============
로그인 시도 (시큐리티 커스텀)
=============SecurityContext에 인증 정보 저장 완료============
=============세션 저장 성공=============
로그인 중인 사용자: test1@abc.com
SecurityContext에 저장된 사용자: test1@abc.com

로그인된 사용자 없음
세션에 저장된 사용자: test1@abc.com

이런식이다.

저장 잘 하는데 메인 페이지로 가져오면서 문제가 생기는 것 같다.
그러니까 로그인 후 요청이 완료된 뒤, SecurityContextHolder에 저장된 인증 정보가 사라지고 있는 것 처럼 보인다.

// SecurityContextPersistenceFilter 추가
.addFilterBefore(new SecurityContextPersistenceFilter(), SecurityContextHolderFilter.class)  // SecurityContext를 세션과 동기화

필터체인에 이거 한줄 추가해줬다. 원래는 자동으로 처리해주는 코드라 필요없는데 정보가 누락되니까 명시를 그냥 해줬다. 그래서 노란줄로 이건 더이상 사용하지 않는다는 안내가 떴지만 무시하고 테스트 해봤다.

드디어 뜨네,,

그리고 로그아웃을 시도해봤는데.

로그인 후 이용하랜다. 뭐가 문제니 대체 일단 저장은 된 것 같으니 인터셉터 문제라고 생각하고, 커스텀 필터와 세션을 담당하는 rq를 살펴보니까 isLogined메서드가 한번도 실행된적이 없는걸 발견했다.

바로 필터체인 수정하러가자고.

아니다. 일단은 글이 너무 길어져서 여기서 끊자.

main 페이지에 로그인한 유저 정보를 불러오는 것 까지도 성공했다. 콘솔에 정확하게 찍히고 프로젝트의 콘솔에서도 정보 저장이 잘 되니까 일단은 문제 해결이다. 인터셉터는 나중에 수정하자.