JWT를 이용한 로그인 구현 (with Rust)
들어가며
이 포스트에서는 보안 기법 중 하나인 JWT을 이용해 로그인과 로그아웃을 구현하는 방법을 간략하게 다뤄본다.
예제 코드는 Rust를 사용했으나, 내용이 언어에 종속되지는 않을 것이다.
JWT의 장점과 단점
이전에 JWT에 대해 정리했던 포스트다.
https://blog.naver.com/sssang97/221972616615
JWT가 내미는 가장 큰 장점은 바로 상태가 없다는(Stateless) 것이다.
한번 만들어서 던져주기만 하고, 서버에서 계속 유지하고 관리할 필요가 없으니 부하도 줄이고 시스템을 단순하게 만들 수 있다는 것이다.
하지만 이 무상태성이 되려 단점이 되기도 한다.
한번 던져주고 나면 관리를 할 수 없기 때문이다!
그래서 순수한 토큰 하나만으로는 로그인에 대한 관리를 하기가 어려운 부분이 많다.
첫째로, 완전한 로그아웃이 불가능하다.
JWT는 토큰 생성시에 만료시간을 미리 정해두는데, 이걸 도중에 간섭해서 바꿀 방법이 없기 때문이다. 발급할때 지정한 만료시간이 로그아웃을 하든말든 계속 유지된다.
둘째로, 로그인 유지 처리가 애매하다.
토큰은 무조건 만료시간을 정해놓고 만들어줘야 한다.
그런데 막 년단위로 만료를 줄 수도 없는 노릇이고, 만료시간을 짧게 잡으면 토큰이 금방 죽어버리니 다시 로그인을 해야 한다. 이것 때문에 아이디와 패스워드를 저장하고 있을 수도 없는 노릇 아닌가?
이 단점들을 보완하기 위해 주로 사용하는 패턴이, 액세스 토큰과 리프레시 토큰 2개를 사용하는 것이다.
리프레시 토큰과 액세스 토큰
액세스 토큰은 말 그대로 접근을 위한 토큰이다. 만료시간을 짧게 잡고 리프레시 토큰을 통해 만료될때마다 재발급을 받도록 한다.
따로 무슨 처리를 하진 않기에 액세스 토큰은 완전하게 stateless하다.
리프레시 토큰은 말 그대로 갱신용 토큰이다.
액세스토큰이 만료됐을 경우에 이것을 통해 액세스 토큰을 다시 발급받는다.
그리고 이 토큰은 서버에서 관리를 할 필요가 있다. 그래야 완전한 로그아웃 처리가 가능하기 때문이다.
RDB나 In-Memory DB를 통해서 리프레시 토큰을 생성할때마다 값을 넣어두고, 로그아웃시에 리프레시 토큰을 사용 불가능하도록 체크해야 한다.
프로세스는 다음과 같다.
- 최초 로그인 시-> 리프레시 토큰과 액세스 토큰 발급 (리프레시 토큰은 DB에 저장)
- 로그인 유지(액세스 토큰 만료) -> 리프레시 토큰을 서버에 전달해 액세스 토큰을 재발급
- 로그아웃 -> 리프레시 토큰을 서버에 전달해 해당 토큰이 DB에서 사용불가하게 체크
그렇게 복잡하진 않다.
다만 프론트 측에서 리프레시 토큰을 보관하는 것이 조금 조심스럽고 민감할 수 있다는 점이 있다.
일단 서버 측에서 로그인은 다음과 같이 구현할 수 있다.
#[derive(Deserialize, Serialize, Debug)]
pub struct LoginParam {
pub email: String,
pub password: String,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct LoginResponse {
pub success: bool,
pub login_failed: bool,
pub access_token: String,
pub refresh_token: String,
pub message: String,
}
// 로그인
#[post("/auth/login")]
pub async fn login(
web::Json(body): web::Json<LoginParam>,
connection: Data<Mutex<PgConnection>>,
) -> impl Responder {
let connection = match connection.lock() {
Err(_) => {
log::error!("database connection lock error");
let response = ServerErrorResponse::new();
return HttpResponse::build(StatusCode::INTERNAL_SERVER_ERROR).json(response);
}
Ok(connection) => connection,
};
let connection: &PgConnection = Borrow::borrow(&connection);
let LoginParam { email, password } = body;
let query = tb_user::dsl::tb_user
.filter(tb_user::dsl::email.eq(&email))
.filter(tb_user::dsl::use_yn.eq(true));
let user_result = query.load::<SelectUser>(connection);
match user_result {
Ok(users) => {
let response = if users.is_empty() {
LoginResponse {
success: false,
login_failed: true,
access_token: "".to_owned(),
refresh_token: "".to_owned(),
message: "login failed".to_owned(),
}
} else {
let user = &users[0];
let salt = &user.salt;
let password = lib::hash(password + salt);
if password == user.password {
// 리프레시 토큰 생성 및 DB에 삽입
let refresh_token =
lib::jwt::create_refresh_token(user.id, user.user_type.clone());
let insert_value = InsertRefreshToken {
token_value: refresh_token.clone(),
user_id: user.id,
};
let execute_result = diesel::insert_into(tb_refresh_token::table)
.values(insert_value)
.execute(connection);
if execute_result.is_err() {
log::error!("refresh token insert query error");
let response = ServerErrorResponse::new();
return HttpResponse::build(StatusCode::INTERNAL_SERVER_ERROR)
.json(response);
}
// 액세스 토큰 생성
let access_token =
lib::jwt::create_access_token(user.id, user.user_type.clone());
LoginResponse {
success: true,
login_failed: false,
access_token: access_token,
refresh_token: refresh_token,
message: "success".to_owned(),
}
} else {
log::info!("로그인 실패: 패스워드 불일치");
LoginResponse {
success: false,
login_failed: true,
access_token: "".to_owned(),
refresh_token: "".to_owned(),
message: "login failed".to_owned(),
}
}
};
HttpResponse::build(StatusCode::OK).json(response)
}
Err(error) => {
log::error!("login select query error: {}", error);
let response = LoginResponse {
success: false,
login_failed: false,
access_token: "".to_owned(),
refresh_token: "".to_owned(),
message: error.to_string(),
};
HttpResponse::build(StatusCode::INTERNAL_SERVER_ERROR).json(response)
}
}
}
달리 특별한 것은 없다. 아이디와 패스워드가 완전히 일치할 경우,
JWT로 리프레시 토큰과 액세스 토큰을 따로 생성해 프론트한테 반환해주고, 리프레시 토큰은 DB에 저장했다.
로그아웃은 다음과 같이 구현했다.
#[derive(Deserialize, Serialize, Debug)]
pub struct LogoutParam {
pub refresh_token: String,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct LogoutResponse {
pub success: bool,
pub message: String,
}
// 로그아웃
#[delete("/auth/logout")]
pub async fn logout(
web::Json(body): web::Json<LogoutParam>,
connection: Data<Mutex<PgConnection>>,
) -> impl Responder {
let connection = match connection.lock() {
Err(_) => {
log::error!("database connection lock error");
let response = ServerErrorResponse::new();
return HttpResponse::build(StatusCode::INTERNAL_SERVER_ERROR).json(response);
}
Ok(connection) => connection,
};
let connection: &PgConnection = Borrow::borrow(&connection);
let token = tb_refresh_token::dsl::tb_refresh_token
.filter(tb_refresh_token::dsl::token_value.eq(&body.refresh_token))
.filter(tb_refresh_token::dsl::dead_yn.eq(false));
// 리프레시 토큰 삭제처리
let result = connection.transaction(|| {
diesel::update(token)
.set(tb_refresh_token::dsl::dead_yn.eq_all(true))
.execute(connection)?;
diesel::update(token)
.set(tb_refresh_token::dsl::dead_utc.eq_all(epoch_timestamp::Epoch::now() as i64))
.execute(connection)
});
match result {
Ok(_) => {
let response = LogoutResponse {
success: true,
message: "logout success".to_owned(),
};
HttpResponse::build(StatusCode::OK).json(response)
}
Err(error) => {
log::error!("logout error: {}", error);
let response = LogoutResponse {
success: false,
message: error.to_string(),
};
HttpResponse::build(StatusCode::INTERNAL_SERVER_ERROR).json(response)
}
}
}
이것도 코드가 길긴 하지만 특별한건 없다.
토큰 관리 테이블 token에 접근해서 전달받은 리프레시 토큰을 사용불가 처리한다.
그리고 로그인 유지를 위한 리프레시는 다음과 같이 구현했다.
그냥 유효한 리프레시 토큰이기만 하면 그대로 액세스 토큰을 만들어서 던져주면 끝이다.
#[derive(Deserialize, Serialize, Debug)]
pub struct RefreshParam {
pub refresh_token: String,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct RefreshResponse {
pub success: bool,
pub expired: bool,
pub access_token: String,
pub message: String,
}
// 액세스 토큰 갱신
#[put("/auth/refresh")]
pub async fn refresh(
web::Json(body): web::Json<RefreshParam>,
connection: Data<Mutex<PgConnection>>,
) -> impl Responder {
let connection = match connection.lock() {
Err(_) => {
log::error!("database connection lock error");
let response = ServerErrorResponse::new();
return HttpResponse::build(StatusCode::INTERNAL_SERVER_ERROR).json(response);
}
Ok(connection) => connection,
};
let connection: &PgConnection = Borrow::borrow(&connection);
use diesel::dsl::{exists, select};
let query = select(exists(
tb_refresh_token::dsl::tb_refresh_token
.filter(tb_refresh_token::dsl::token_value.eq(&body.refresh_token))
.filter(tb_refresh_token::dsl::dead_yn.eq(false)),
));
let result = query.get_result(connection);
match result {
Ok(exists) => {
if exists {
let decoded_result = jwt::verify(body.refresh_token);
let response = match decoded_result {
Some(user_id) => {
let query = tb_user::dsl::tb_user.filter(tb_user::dsl::id.eq(&user_id));
let result = query.load::<SelectUser>(connection);
match result {
Ok(select_user) => {
let response = if select_user.is_empty() {
RefreshResponse {
success: false,
expired: false,
access_token: "".into(),
message: "user not exists".to_owned(),
}
} else {
let user_type = select_user[0].user_type.clone();
// 액세스 토큰 생성
let access_token =
lib::jwt::create_access_token(user_id, user_type);
RefreshResponse {
success: true,
expired: false,
access_token: access_token,
message: "refresh success".to_owned(),
}
};
HttpResponse::build(StatusCode::OK).json(response)
}
Err(error) => {
log::error!("database error: {:?}", error);
let response = ServerErrorResponse::new();
HttpResponse::build(StatusCode::OK).json(response)
}
}
}
None => {
let response = RefreshResponse {
success: false,
expired: true,
access_token: "".into(),
message: "logout failed".to_owned(),
};
HttpResponse::build(StatusCode::OK).json(response)
}
};
response
} else {
let response = RefreshResponse {
success: false,
expired: true,
access_token: "".into(),
message: "logout failed".to_owned(),
};
HttpResponse::build(StatusCode::OK).json(response)
}
}
Err(error) => {
log::error!("database error: {:?}", error);
let response = ServerErrorResponse::new();
return HttpResponse::build(StatusCode::INTERNAL_SERVER_ERROR).json(response);
}
}
}
이것도 코드가 길지만 그리 어려울 것은 없다.
그리고 내 경우에는 그냥 RDB인 Postgresql을 사용해서 토큰을 관리했는데, 좀더 성능에 신경을 쓴다면 Redis 등의 In-Memory DB를 사용하는 것이 좋을 것이다.
대충 이런식으로 사용하면 된다.
아래는 예시 코드의 원본 레포지토리 링크다.
https://github.com/myyrakle/rustywiki-server/blob/master/src/routes/auth.rs