Updated:

 MySQL을 사용하여 회원가입 기능을 구현하기 위해서 다음과 같은 dependency를 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
testImplementation 'org.springframework.security:spring-security-test'

runtimeOnly 'com.mysql:mysql-connector-j'

데이터베이스 테이블 설계

MySQL CREATE TABLE

 먼저 회원의 정보를 저장할 데이터베이스를 생성한다. CREATE문을 통해 데이터베이스를 생성하고, USE를 통해 해당 데이터베이스를 사용한다.

CREATE DATABASE AIML;
USE AIML;

 회원가입 기능을 구현해야 하므로, 다음과 같이 TABLE을 생성한다.

CREATE TABLE Member(
	id VARCHAR(20) NOT NULL,
    password VARCHAR(20) NOT NULL,
    name VARCHAR(5) NOT NULL,
    phoneNum VARCHAR(13) NOT NULL,
    CONSTRAINT Member_PK PRIMARY KEY(id)
);

MEMBER Class

 회원가입을 할 때, 우선 ID, Password가 필요하고 이름, 전화번호를 MyPage에 출력하는 것을 해야하므로, Member에 위 Attribute를 설정하였다. 이후, 다음과 같은 Java 코드를 작성한다.

package com.Member.aiml_server_2024.userInfo;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor  // JPA를 이용하기 위해 기본 생성자를 Lombok으로 선언
@Getter
@Entity(name = "Member")    // 데이터베이스 테이블과 매핑되는 클래스임을 선언
public class Member {
    @Id // id 칼럼을 MEMBER 테이블의 기본키로 설정
    private String id;
    private String password;
    private String name;
    private String phoneNum;

    @Builder
    public Member(String id, String password, String name, String phoneNum) {
        this.id = id;
        this.password = password;
        this.name = name;
        this.phoneNum = phoneNum;
    }

    @Getter
    @Setter
    @NoArgsConstructor
    public static class SaveRequest {
        private String id;
        private String password;
        private String name;
        private String phoneNum;

        @Transient
        public Member toEntity() {
            return Member.builder()
                    .id(this.id)
                    .password(this.password)
                    .name(this.name)
                    .phoneNum(this.phoneNum)
                    .build();
        }

    }

}

DTO(Data Transfer Object) 클래스는 데이터 전송을 담당하는 클래스로, 위와같이 Member 클래스의 내부 정적 클래스로 선언했다.

application.properties

 application.properties에 다음 내용을 추가한다.

## Spring JDBC 연동 정보 설정
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url = jbcc:mysql://localhost:3306/aiml?serverTimezone=Asia/Seoul
spring.datasource.username=root
spring.datasource.password=//MySQL 비밀번호

##Spring Data JPA
#true 설정시 JPA 쿼리문 확인 가능
spring.jpa.show-sql=true
# 데이터베이스 생성 쿼리를 보기 위해 create로 설정
spring.jpa.hibernate.ddl-auto=create

Spring Security 설정

기존 API 설정

 먼저 Spring Security를 사용하면 기존 Firebase의 데이터를 가져오는 API를 호출하면 401 Error가 발생하므로 다음과 같이 SecurityConfig.java를 생성해준다.

package com.Member.aiml_server_2024.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;

@Configuration
@EnableWebSecurity  // Spring Security 설정 활성화
@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)
public class SecurityConfig {

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web -> web.ignoring().requestMatchers("/location/**", "/UserInfo/get"));
        // 예외처리 하고 싶은 url
    }
}

 WebSecurityCustomizer는 Spring Security 설정을 커스터마이징하기 위한 인터페이스다. 이 메서드가 수행하는 역할은 특정 URL 패턴을 Spring Security의 인증 및 권한 검사에서 예외 처리하는 것이다. 즉, 해당 경로들에 대해서는 보안 필터를 적용하지 않고 접근을 허용하게 된다.
 이제 SecurityFilterChain과 PasswordEncoder 메서드를 만들어본다.

package com.Member.aiml_server_2024.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)
public class SecurityConfig {

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web -> web.ignoring().requestMatchers("/location/**", "/UserInfo/get"));
        // 예외처리 하고 싶은 url
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/").authenticated()
                        .anyRequest().permitAll()
                )
                .formLogin(form -> form
                        .loginPage("/login")    // 사용자 정의 로그인 페이지
                        .loginProcessingUrl("/api/login")   // 로그인 처리 URL
                        .defaultSuccessUrl("/") // 로그인 성공 시 이동할 URL
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")   // 로그이웃 처리 URL
                )
                .csrf(csrf -> csrf.disable());

        return httpSecurity.build();
    }

    @Bean
    public PasswordEncoder getPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

 이제 메인 페이지인 “/” 페이지는 인증받은 사용자만 접근할 수 있으며, 인증받지 않은 사용자가 접근할 경우 로그인 페이지 “/login”로 이동하게 된다. 또한 사용자가 입력한 로그인 폼을 “/api/login”으로 POST method로 전송하면 loginProcessingUrl() 메서드를 통해 해당 요청이 될 시 SpringSecurity가 직접 알아서 로그인 과정을 진행해준다.

Spring

LoginController

 사용자가 요청한 페이지를 반환해주는 Controller를 작성한다.

package com.Member.aiml_server_2024.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class LoginController {
    @GetMapping("/")
    public String main() {
        return "main";
    }

    @GetMapping("/login")
    public String login() {
        return "login";
    }

    @GetMapping("/register")
    public String register() {
        return "register";  // 회원가입 페이지
    }
}

 이제 인증받지 않은 사용자가 메인페이지 “/”에 접근하면 로그인 페이지를 반환하거나 회원가입 후 로그인 페이지로 접근할 수 있게 된다.

MemberApiController, MemberService, MemberRepository

package com.Member.aiml_server_2024.controller;

import com.Member.aiml_server_2024.userInfo.Member;
import com.Member.aiml_server_2024.userInfo.MemberService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class MemberApiController {
    private final MemberService memberService;

    public MemberApiController(MemberService memberService) {
        this.memberService = memberService;
    }

    @PostMapping("/api/member")
    public void save(@RequestBody Member.SaveRequest member) {
        memberService.save(member);
    }
}
package com.Member.aiml_server_2024.userInfo;

import org.springframework.transaction.annotation.Transactional;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class MemberService implements UserDetailsService {
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    public MemberService(MemberRepository memberRepository, PasswordEncoder passwordEncoder) {
        this.memberRepository = memberRepository;
        this.passwordEncoder = passwordEncoder;
    }

    public void save(Member.SaveRequest member) {
        member.setPassword(passwordEncoder.encode(member.getPassword()));
        memberRepository.save(member.toEntity());
    }

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findById(username)
                .orElseThrow(() -> new UsernameNotFoundException("username"));

        return toUserDetails(member);
    }

    private UserDetails toUserDetails(Member member) {
        return User.builder()
                .username(member.getId())
                .password(member.getPassword())
                .build();
    }
}
package com.Member.aiml_server_2024.userInfo;

import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, String> {
}

PostMan 실행 결과

Flutter

 먼저 main.dart는 다음과 같이 해주었다.

import 'package:flutter/material.dart';
import 'package:login_page/HomeScreen.dart';
import 'package:login_page/MyPage.dart';
import 'package:login_page/Navigation.dart';
import 'package:login_page/SignIn.dart';

void main() async {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super (key : key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      initialRoute: '/',
      routes: {
        '/': (context) => HomeScreen(),
        '/infra_info': (context) => MyPage(),
        '/navigator': (context) => SignIn(),
        '/message': (context) => Navigation()
      },
    );
  }
}

 Flutter에서 로그인을 구현하기 위해서 Spring의 SecurityConfig.java를 다음과 같이 수정하였다.

package com.Member.aiml_server_2024.config;

import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)
public class SecurityConfig {

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web -> web.ignoring().requestMatchers("/location/**", "/UserInfo/get"));
        // 예외처리 하고 싶은 url
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/infra_info").authenticated()
                        .anyRequest().permitAll()
                )
                .formLogin(form -> form
                                .loginPage("/navigator")    // 사용자 정의 로그인 페이지
                                .loginProcessingUrl("/api/login")   // 로그인 처리 URL
                                .usernameParameter("id")
                                .passwordParameter("password")
//                        .defaultSuccessUrl("/infra_info", true) // 로그인 성공 시 이동할 URL
                                .successHandler((request, response, authentication) -> {
                                    response.setStatus(HttpServletResponse.SC_OK);
                                    response.setContentType("application/json");
                                    response.getWriter().write("{\"message\": \"Login successful\"}");
                                    response.getWriter().flush();
                                })
                                .failureHandler((request, response, exception) -> {
                                    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                                    response.setContentType("application/json");
                                    response.getWriter().write("{\"message\": \"Login failed\"}");
                                    response.getWriter().flush();
                                })
                                .permitAll()    // 로그인 페이지 접근 허용
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")   // 로그이웃 처리 URL
                )
                .csrf(csrf -> csrf.disable());

        return httpSecurity.build();
    }

    @Bean
    public PasswordEncoder getPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

 다음은 SignIn.dart이다.

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';

import 'package:login_page/MyPage.dart';
import 'package:login_page/JoinMember.dart';

class SignIn extends StatefulWidget {
  @override
  _SignInState createState() => _SignInState();
}

class _SignInState extends State<SignIn> {
  final TextEditingController _idController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();

  Future<void> _signIn(String id, String password) async {
    // String id = _idController.text; // 'id'로 변경
    // String password = _passwordController.text;

    final url = Uri.parse('http://10.0.2.2:8081/api/login');

    final response = await http.post(
      url,
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: {'id': id, 'password': password},
    );

    if(response.statusCode == 200) {
      print('Login succesful');
      Navigator.pushReplacement(
        context,
        MaterialPageRoute(builder: (context)=> MyPage()),
      );
    } else {
      print('Login failed: ${response.statusCode}, ${response.body}');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('로그인'),
      ),
      body: Padding(
        padding: EdgeInsets.all(20.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            SizedBox(height: 10.0),
            TextField(
              controller: _idController,  // 'id'를 입력받기 위한 컨트롤러
              decoration: InputDecoration(
                labelText: "id",
                // border: OutlineInputBorder(),
                // hintText: "ID를 입력하세요",  // 프롬프트 수정
              ),
            ),
            SizedBox(height: 10.0),
            TextField(
              controller: _passwordController,
              decoration: InputDecoration(
                labelText: 'Password',
                // border: OutlineInputBorder(),
                // hintText: "비밀번호를 입력하세요",
              ),
              obscureText: true,
            ),
            SizedBox(height: 10.0),
            ElevatedButton(
              onPressed: (){
                _signIn(_idController.text, _passwordController.text);
              },
              child: Text('Login'),
              // onPressed: _signIn,
              // child: Text('로그인'),
            ),
            ElevatedButton(
                onPressed: (){
                  Navigator.push(
                    context,
                    MaterialPageRoute(builder: (context) => JoinMember())
                  );
            }, child: Text('회원가입'))
          ],
        ),
      ),
    );
  }
}

 위 페이지에서 회원가입 버튼을 클릭하면 다음과 같은 페이지로 이동한다.

import 'package:flutter/cupertino.dart';
import 'package:http/http.dart' as http;
import 'package:flutter/material.dart';
import 'dart:convert';

import 'package:login_page/SignIn.dart';

class JoinMember extends StatefulWidget {
  @override
  _JoinMemberState createState() => _JoinMemberState();
}

class _JoinMemberState extends State<JoinMember> {
  final TextEditingController _idController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();
  final TextEditingController _nameController = TextEditingController();
  final TextEditingController _phoneNumController = TextEditingController();


  Future<void> _Join(String id, String password, String name, String phoneNum) async {
    final url = Uri.parse('http://10.0.2.2:8081/api/member');

    final response = await http.post(
      url,
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({'id': id, 'password': password, 'name': name, 'phoneNum': phoneNum}),
    );

    if(response.statusCode == 200) {
      print('Signup succesful');
      Navigator.pushReplacement(
        context,
        MaterialPageRoute(builder: (context)=> SignIn()),
      );
    } else {
      print('Signup failed: ${response.body}');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('회원가입, 나중에 이 Text 삭제 예정'),
      ),
      body: Padding(padding: EdgeInsets.all(20.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          SizedBox(height: 10.0),
          TextField(
            controller: _idController,
            decoration: InputDecoration(
              labelText: "아이디"
            ),
          ),
          SizedBox(height: 10.0),
          TextField(
            controller: _passwordController,
            decoration: InputDecoration(
                labelText: "비밀번호"
            ),
          ),
          SizedBox(height: 10.0),
          TextField(
            controller: _nameController,
            decoration: InputDecoration(
                labelText: "이름"
            ),
          ),
          SizedBox(height: 10.0),
          TextField(
            controller: _phoneNumController,
            decoration: InputDecoration(
                labelText: "전화번호"
            ),
          ),
          SizedBox(height: 10.0),
          ElevatedButton(onPressed: () {
            _Join(_idController.text, _passwordController.text, _nameController.text, _phoneNumController.text);
          }, child: Text('회원가입'),)
        ],
      )),
    );
  }

}

 이제 로그인에 성공하면 다음과 같은 페이지로 이동한다.

import 'widget/CommonScaffold.dart';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';

class MyPage extends StatefulWidget{
  //const Member({Key? key}) : super(key: key);
  @override
  _MyPageState createState() => _MyPageState();
}

class _MyPageState extends State<MyPage> {
  @override
  Widget build(BuildContext context) {
    return CommonScaffold(
      title: Text("내 정보"),
      body: SingleChildScrollView(
        child: Center(
          child: Container(
            decoration: BoxDecoration(
              //border: Border.all(color: Colors.black),
              borderRadius: BorderRadius.circular(10.0),
              color: Color(0xffD9D9D9),
            ),
            margin: EdgeInsets.all(20.0),
            padding: EdgeInsets.all(20.0),
            child: Column(
              mainAxisSize: MainAxisSize.max,
              children: [
                Container(
                  width: 400,
                  height: 55,
                  // margin: EdgeInsets.only(right: .0),
                  decoration: BoxDecoration(
                    color: Colors.white,
                    borderRadius: BorderRadius.circular(10.0),
                  ),
                  // padding: EdgeInsets.all(10.0),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.end,
                    children: [
                      SizedBox(height: 5.0,),
                      Text(
                        '홍길동',
                        style: TextStyle(fontSize: 16.0,),
                        textAlign: TextAlign.right,
                      ),
                      SizedBox(height: 5.0,),
                      Text(
                        'AAA @ email.com',
                        style: TextStyle(fontSize: 11.0, color: Colors.grey),
                      ),
                    ],
                  ),
                ),
                SizedBox(height: 20.0),
                Row(
                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                  children: [
                    Container(
                      decoration: BoxDecoration(
                        //color: Colors.yellow,
                      ),
                      child: TextButton(
                        onPressed: () {
                          // 첫 번째 버튼 동작
                        },
                        child: Text('개인정보수정',
                          style: TextStyle(fontSize: 11.0),
                        ),
                      ),
                    ),
                    Container(
                      decoration: BoxDecoration(
                        //color: Colors.yellow,
                      ),
                      child: TextButton(
                        onPressed: () {
                          // 두 번째 버튼 동작
                        },
                        child: Text('로그아웃',
                          style: TextStyle(fontSize: 11.0),
                        ),
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

 이제 로그인 시 사용자의 정보를 가져오는 기능을 구현해본다.

사용자 정보 가져오는 기능

 먼저 사용자의 이름과 전화번호를 출력하기 위해서 다음과같이 Member.java를 수정한다.

package com.Member.aiml_server_2024.userInfo;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Transient;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@NoArgsConstructor  // JPA를 이용하기 위해 기본 생성자를 Lombok으로 선언
@Getter
@Entity(name = "Member")    // 데이터베이스 테이블과 매핑되는 클래스임을 선언
public class Member {
    @Id // id 칼럼을 MEMBER 테이블의 기본키로 설정
    private String id;
    private String password;
    private String name;
    private String phoneNum;
//    private MemberAuthority authority; // USER인지 ADMIN인지 구분

    @Builder
    public Member(String id, String password, String name, String phoneNum) {
        this.id = id;
        this.password = password;
        this.name = name;
        this.phoneNum = phoneNum;
    }

    public static class SafeInfo {


        private String id;
        private String name;
        private String phoneNum;

        public SafeInfo(String id, String name, String phoneNum) {
            this.id = id;
            this.name = name;
            this.phoneNum = phoneNum;
        }
        public String getId() {
            return id;
        }

        public String getName() {
            return name;
        }

        public String getPhoneNum() {
            return phoneNum;
        }

    }

    @Getter
    @Setter
    @NoArgsConstructor
    public static class SaveRequest {
        private String id;
        private String password;
        private String name;
        private String phoneNum;

        @Transient
        public Member toEntity() {
            return Member.builder()
                    .id(this.id)
                    .password(this.password)
                    .name(this.name)
                    .phoneNum(this.phoneNum)
                    .build();
        }

    }

}

 또한 이 과정에서 Jwt를 이용해야하므로, JwtTokenProvider.java를 생성한다.

package com.Member.aiml_server_2024.config.auth;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import org.springframework.stereotype.Component;

import java.util.Base64;
import java.util.Date;

@Component
public class JwtTokenProvider {

    // JWT 토큰 서명에 사용할 키
    private final String SECRET_KEY = Base64.getEncoder().encodeToString(Keys.secretKeyFor(SignatureAlgorithm.HS256).getEncoded());

    // JWT 토큰 생성 메서드
    public String createdToken(String id) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + 86400000);   // 24 시간동안 유요한 토큰

        return Jwts.builder()
                .setSubject(id)
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .compact();
    }

    public String getMemberIdFromToken(String token) {
        Claims claims = Jwts.parser()
                .setSigningKey(SECRET_KEY)
                .build()
                .parseClaimsJws(token)
                .getBody();

        return claims.getSubject();
    }

}

 또한 SecurityConfig.java와 MemberApiController.java를 다음과 같이 수정한다.

package com.Member.aiml_server_2024.config.auth;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import org.springframework.stereotype.Component;

import java.util.Base64;
import java.util.Date;

@Component
public class JwtTokenProvider {

    // JWT 토큰 서명에 사용할 키
    private final String SECRET_KEY = Base64.getEncoder().encodeToString(Keys.secretKeyFor(SignatureAlgorithm.HS256).getEncoded());

    // JWT 토큰 생성 메서드
    public String createdToken(String id) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + 86400000);   // 24 시간동안 유요한 토큰

        return Jwts.builder()
                .setSubject(id)
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .compact();
    }

    public String getMemberIdFromToken(String token) {
        Claims claims = Jwts.parser()
                .setSigningKey(SECRET_KEY)
                .build()
                .parseClaimsJws(token)
                .getBody();

        return claims.getSubject();
    }

}
package com.Member.aiml_server_2024.controller;

import com.Member.aiml_server_2024.config.auth.JwtTokenProvider;
import com.Member.aiml_server_2024.userInfo.Member;
import com.Member.aiml_server_2024.userInfo.MemberRepository;
import com.Member.aiml_server_2024.userInfo.MemberService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

@RestController
public class MemberApiController {
    private final MemberService memberService;
    private final JwtTokenProvider jwtTokenProvider;
    private final MemberRepository memberRepository;

    public MemberApiController(MemberService memberService, JwtTokenProvider jwtTokenProvider, MemberRepository memberRepository) {
        this.memberService = memberService;
        this.jwtTokenProvider = jwtTokenProvider;
        this.memberRepository = memberRepository;
    }

    @PostMapping("/api/member")
    public void save(@RequestBody Member.SaveRequest member) {
        memberService.save(member);
    }

//    @GetMapping("/api/member/info")
//    public ResponseEntity<Member> getMemberInfo(Authentication authentication) {
//        String id = authentication.getName();
//        Member member = memberService.getUserById(id);
//        return ResponseEntity.ok(member);
//    }

    @GetMapping("/api/member/info")
    public ResponseEntity<Member.SafeInfo> getMemberInfo(@RequestHeader("Authorization") String token) {
        String jwt = token.replace("Bearer ", "");
        String memberId = jwtTokenProvider.getMemberIdFromToken(jwt);
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new IllegalArgumentException("Member not found"));

        Member.SafeInfo safeInfo =
                new Member.SafeInfo(member.getId(), member.getName(), member.getPhoneNum());

        return ResponseEntity.ok(safeInfo);
    }
}

 Flutter에서 jwt 토큰을 받을 수 있는 함수를 구현한다.

import 'package:shared_preferences/shared_preferences.dart';

// JWT 토큰을 저장하는 함수
Future<void> saveJwtToken(String token) async{
  SharedPreferences prefs = await SharedPreferences.getInstance();
  await prefs.setString('jwtToken', token);
}

// JWT 토큰을 불러오는 함수
Future<String?> getJwtToken() async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
  return prefs.getString('jwtToken');
}

// JWT 토큰을 삭제하는 함수
Future<void> removeJwtToken() async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
  await prefs.remove('jwtToken');
}

 이제 Flutter의 MyPage.dart를 다음과 같이 수정해주면 로그인한 사용자의 정보가 출력된다.

import 'dart:convert';

import 'package:login_page/token_storage.dart';

import 'widget/CommonScaffold.dart';
import 'package:http/http.dart' as http;
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';

class MyPage extends StatefulWidget{
  //const Member({Key? key}) : super(key: key);
  @override
  _MyPageState createState() => _MyPageState();
}

class _MyPageState extends State<MyPage> {
  String userName = '';
  String userPhone = '';

  @override
  void initState() {
    super.initState();
    fetchUserInfo();  // 페이지 로드 시 사용자 정보 가져오기
  }

  Future<void> fetchUserInfo() async {
    String? jwtToken = await getJwtToken();

    if(jwtToken != null) {
      final response = await http.get(
        Uri.parse('http://10.0.2.2:8081/api/member/info'),
        headers: {
          'Authorization' : 'Bearer $jwtToken',
        },
      );

      if(response.statusCode == 200) {
        var userData = jsonDecode(response.body);
        setState(() {
          userName = userData['name'] ?? 'Unknown'; // name이 null이면 'Unknown'
          userPhone = userData['phoneNum'] ?? 'No phone';
        });
      } else {
        print("Failed to load user info: ${response.statusCode}, ${response.body}");
      }
    } else {
      print("JWT Token is missing");
    }

  }

  @override
  Widget build(BuildContext context) {
    return CommonScaffold(
      title: Text("내 정보"),
      body: SingleChildScrollView(
        child: Center(
          child: Container(
            decoration: BoxDecoration(
              //border: Border.all(color: Colors.black),
              borderRadius: BorderRadius.circular(10.0),
              color: Color(0xffD9D9D9),
            ),
            margin: EdgeInsets.all(20.0),
            padding: EdgeInsets.all(20.0),
            child: Column(
              mainAxisSize: MainAxisSize.max,
              children: [
                Container(
                  width: 400,
                  height: 55,
                  // margin: EdgeInsets.only(right: .0),
                  decoration: BoxDecoration(
                    color: Colors.white,
                    borderRadius: BorderRadius.circular(10.0),
                  ),
                  // padding: EdgeInsets.all(10.0),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.end,
                    children: [
                      SizedBox(height: 5.0,),
                      Text(
                        userName.isNotEmpty ? userName : '이름을 불러오는 중...',
                        style: TextStyle(fontSize: 16.0,),
                        textAlign: TextAlign.right,
                      ),
                      SizedBox(height: 5.0,),
                      Text(
                        userPhone.isNotEmpty ? userPhone : '전화번호흫 불러오는 중...',
                        style: TextStyle(fontSize: 11.0, color: Colors.grey),
                      ),
                    ],
                  ),
                ),
                SizedBox(height: 20.0),
                Row(
                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                  children: [
                    Container(
                      decoration: BoxDecoration(
                        //color: Colors.yellow,
                      ),
                      child: TextButton(
                        onPressed: () {
                          // 첫 번째 버튼 동작
                        },
                        child: Text('개인정보수정',
                          style: TextStyle(fontSize: 11.0),
                        ),
                      ),
                    ),
                    Container(
                      decoration: BoxDecoration(
                        //color: Colors.yellow,
                      ),
                      child: TextButton(
                        onPressed: () {
                          // 두 번째 버튼 동작
                        },
                        child: Text('로그아웃',
                          style: TextStyle(fontSize: 11.0),
                        ),
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

로그아웃

 이제 마지막으로 로그아웃 기능을 구현해본다.
 로그아웃시 이동할 페이지를 설정해주기 위해서 SecurityConfig.java를 다음과 같이 수정한다.

package com.Member.aiml_server_2024.config;

import com.Member.aiml_server_2024.config.auth.JwtTokenProvider;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

import java.util.HashMap;
import java.util.Map;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)
public class SecurityConfig {

    @Autowired
    private JwtTokenProvider jwtTokenProvider;

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web -> web.ignoring().requestMatchers("/location/**", "/UserInfo/get"));
        // 예외처리 하고 싶은 url
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/infra_info").authenticated()
                        .anyRequest().permitAll()
                )
                .formLogin(form -> form
                                .loginPage("/navigator")    // 사용자 정의 로그인 페이지
                                .loginProcessingUrl("/api/login")   // 로그인 처리 URL
                                .usernameParameter("id")
                                .passwordParameter("password")
//                        .defaultSuccessUrl("/infra_info", true) // 로그인 성공 시 이동할 URL
                                .successHandler((request, response, authentication) -> {
                                    String userId = authentication.getName();

                                    // 로그인 성공 시 JWT 토큰 생성
                                    String token = jwtTokenProvider.createdToken(userId);

                                    response.setStatus(HttpServletResponse.SC_OK);
                                    response.setContentType("application/json");
                                    Map<String, String> responseData = new HashMap<>();
                                    responseData.put("token", token);
                                    responseData.put("message", "Login successful");
                                    ObjectMapper objectMapper = new ObjectMapper();
                                    response.getWriter().write(objectMapper.writeValueAsString(responseData));
                                    response.getWriter().flush();
                                })
                                .failureHandler((request, response, exception) -> {
                                    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                                    response.setContentType("application/json");
                                    response.getWriter().write("{\"message\": \"Login failed\"}");
                                    response.getWriter().flush();
                                })
                                .permitAll()    // 로그인 페이지 접근 허용
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")   // 로그이웃 처리 URL
                        .logoutSuccessUrl("/navigator")
                )
                .csrf(csrf -> csrf.disable());

        return httpSecurity.build();
    }

    @Bean
    public PasswordEncoder getPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

 이제 Flutter의 MyPage.dart를 다음과 같이 수정해준다.

import 'dart:convert';

import 'package:login_page/token_storage.dart';

import 'widget/CommonScaffold.dart';
import 'package:http/http.dart' as http;
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';

class MyPage extends StatefulWidget{
  //const Member({Key? key}) : super(key: key);
  @override
  _MyPageState createState() => _MyPageState();
}

class _MyPageState extends State<MyPage> {
  String userName = '';
  String userPhone = '';

  @override
  void initState() {
    super.initState();
    fetchUserInfo();  // 페이지 로드 시 사용자 정보 가져오기
  }

  Future<void> fetchUserInfo() async {
    String? jwtToken = await getJwtToken();

    if(jwtToken != null) {
      final response = await http.get(
        Uri.parse('http://10.0.2.2:8081/api/member/info'),
        headers: {
          'Authorization' : 'Bearer $jwtToken',
        },
      );

      if(response.statusCode == 200) {
        var userData = jsonDecode(response.body);
        setState(() {
          userName = userData['name'] ?? 'Unknown'; // name이 null이면 'Unknown'
          userPhone = userData['phoneNum'] ?? 'No phone';
        });
      } else {
        print("Failed to load user info: ${response.statusCode}, ${response.body}");
      }
    } else {
      print("JWT Token is missing");
    }

  }

  Future<void> logout() async {
    // JWT 토큰 가져오기
    String? jwtToken = await getJwtToken();

    if(jwtToken != null) {
      final response = await http.post(
        Uri.parse('http://10.0.2.2:8081/logout'),
        headers: {
          'Authorization': 'Bearer $jwtToken',
        },
      );

      if(response.statusCode == 200) {
        // 로그아웃 성공 시 JWT 토큰 삭제
        await removeJwtToken();

        // 로그아웃 시 로그인 페이지로 이동
        Navigator.pushReplacementNamed(context, '/navigator');
      } else if(response.statusCode == 302) { // 302 에러
        var redirectUrl = response.headers['location'];
        if(redirectUrl != null) {
          print("Redirecting to: $redirectUrl");
          Navigator.pushReplacementNamed(context, '/navigator');
        }
      } else {
        print("Failed to logout: ${response.statusCode}, ${response.body}");
      }
    } else {
      print("JWT Token is missing");
    }
  }

  @override
  Widget build(BuildContext context) {
    return CommonScaffold(
      title: Text("내 정보"),
      body: SingleChildScrollView(
        child: Center(
          child: Container(
            decoration: BoxDecoration(
              //border: Border.all(color: Colors.black),
              borderRadius: BorderRadius.circular(10.0),
              color: Color(0xffD9D9D9),
            ),
            margin: EdgeInsets.all(20.0),
            padding: EdgeInsets.all(20.0),
            child: Column(
              mainAxisSize: MainAxisSize.max,
              children: [
                Container(
                  width: 400,
                  height: 55,
                  // margin: EdgeInsets.only(right: .0),
                  decoration: BoxDecoration(
                    color: Colors.white,
                    borderRadius: BorderRadius.circular(10.0),
                  ),
                  // padding: EdgeInsets.all(10.0),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.end,
                    children: [
                      SizedBox(height: 5.0,),
                      Text(
                        userName.isNotEmpty ? userName : '이름을 불러오는 중...',
                        style: TextStyle(fontSize: 16.0,),
                        textAlign: TextAlign.right,
                      ),
                      SizedBox(height: 5.0,),
                      Text(
                        userPhone.isNotEmpty ? userPhone : '전화번호흫 불러오는 중...',
                        style: TextStyle(fontSize: 11.0, color: Colors.grey),
                      ),
                    ],
                  ),
                ),
                SizedBox(height: 20.0),
                Row(
                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                  children: [
                    Container(
                      decoration: BoxDecoration(
                        //color: Colors.yellow,
                      ),
                      child: TextButton(
                        onPressed: () {
                          // 첫 번째 버튼 동작
                        },
                        child: Text('개인정보수정',
                          style: TextStyle(fontSize: 11.0),
                        ),
                      ),
                    ),
                    Container(
                      decoration: BoxDecoration(
                        //color: Colors.yellow,
                      ),
                      child: TextButton(
                        onPressed: () {
                          // 두 번째 버튼 동작
                          logout();
                        },
                        child: Text('로그아웃',
                          style: TextStyle(fontSize: 11.0),
                        ),
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

 이제 로그아웃 버튼을 클릭하면 302에러가 발생하지만 로그아웃이 처리됨을 볼 수 있다.

댓글남기기