Python 3.8.0 : language
Flask 1.1.2 : web framework
Git : cooperation and version management tool
Redis : Caching database
PostgreSQL : Database
Alembic : migration tool
SQLalchemy : ORM(Object Relational Mapping)
Bcrypt : password hashing
JWT : token generating
유저테이블의 auth_types_id 컬럼이 auth_types 테이블을 외래키로 가지면서 유저에게 권한 타입이 부여되고 권한의 종류에 따라 취할 수 있는 action이 달라진다
로그인, 회원가입에 성공하면 토큰이 발행되는데, 토큰은 클라이언트에게 바로 직접적으로 리턴되지 않고 session storage역할을 하는 redis 저장공간에 key-value형태로 발행된다. 따라서 redis 저장공간에 발행된 토큰의 key를 데이터베이스 random_keys 테이블에 저장한다
유저 권한 타입이 master인 유저는 게시판을 생성/수정/삭제 할 수 있다. 게시판의 작성자의 권한타입은 uploader 컬럼을 통해서 확인할 수 있다.
게시판의 이름을 수정하거나 게시판을 삭제하면 작성자(modifier) 컬럼에 값이 추가되고, updated_at컬럼에 업데이트 날짜가 기록된다. 게시판의 작성자(uploader)는 수장자(modifier)과 다를 수 있다. 마스터권한을 가진 유저라면 게시판을 생성/수정/삭제할 수 있기 때문이다
게시물은 특정 게시판 아래에서 생성되기 때문에 게시판 테이블의 id를 외래키로 가지며 게시물 또한 작성자(uploader)와 수정자(modifier)의 id를 가진다. 게시물의 수정자(modifier)는 게시물을 작성한 유저만 될 수 있다. 따라서 게시물은 작성자(uploader)와 수정자(modifier)가 일치한다
flask_request_validator를 통한 request body 유효성 검사(이메일 중복체크, 이메일 형식체크 등)
bcrypt 비밀번호 암호화 : 단방향 암호화, 로그인 시 해쉬값을 비교
회원가입 성공 시 jwt 토큰(유효기간 6일)을 redis에 uuid를 키(key)로 하여 저장
"8b3b9a0b-5fbc-47d6-a113-5479d33aec7f":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6OCwiZXhwIjoxNTg4NzY0MTU3fQ.W_gWd9sXPpLaY6teppDYgCKRW8rFz8O5-_vMcfzS6jM"
flask_request_validator를 통한 이메일, 패스워드 유효성검사
bcrypt checkpw를 통해서 해쉬된 패스워드 비교
로그인 성공 시 jwt 토큰(유효기간 6일)을 redis에 uuid를 키(key)로 하여 저장
"8b3b9a0b-5fbc-47d6-a113-5479d33aec7f":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6OCwiZXhwIjoxNTg4NzY0MTU3fQ.W_gWd9sXPpLaY6teppDYgCKRW8rFz8O5-_vMcfzS6jM"
[1] Session = sessionmaker(bind=engine)
session = Session()
[2] if session.query(Board.is_deleted).filter(Board.id == board_info['board_id']).one()[0]:
return jsonify({'message': 'BOARD_NOT_EXISTS'}), 404
[3] (session
.query(Board)
.filter(Board.id == board_info['board_id'])
.update({'is_deleted': board_info['is_deleted'], 'modifier': board_info['modifier']}))
[4] (session
.query(Article)
.filter(Article.board_id == board_info['board_id'])
.update({'is_deleted': board_info['is_deleted'], 'modifier': board_info['modifier']}))
[5] session.commit()
return jsonify({'message': 'SUCCESS'}), 200
UI에서 게시판이 삭제되면 해당 게시판이 보이지 않기 때문에 게시물로 접근할 수 없겠지만, 서버를통해서 삭제된 게시판에 있는 게시물로 접근할 수 있기 때문에 삭제된 게시판의 게시물도 함께 삭제해준다
[1] 우선 데이터베이스에 접근하는 세션을 열어준다
[2] path파라미터로 들어온 게시판 아이디가 데이터베이스에 없으면 애러를 리턴한다
[3] 삭제하고자 하는 게시판의 is_deleted 컬럼의 값을 True로 바꿔준다
[4] 삭제하고자 하는 게시판의 게시물의 is_deleted 컬럼의 값을 모두 True로 바꿔준다
[5] 데이터베이스 변경사항을 session commit을 통해서 확정한다
클라이언트의 세션스토리지를 redis로 대체하여 로그아웃을 백엔드 서버 쪽에서 구현했던 것이 새로운 개념이었다. 소스코드는 여기를 통해서 확인하자
데이터베이스에 redis 접근 key를 저장하는 이유는 로그아웃 기능 때문이다. 로그아웃 기능을 살펴보자.
로그아웃 기능을 알기 위해서는 토큰의 역할을 분명히 알아야한다.
- 로그인 시 서버는 클라이언트에게 토큰을 발행 한다
- 클라이언트는 그 토큰을 세션스토리지에 저장한다.
- 토큰이 필요한 api를 호출할 때 마다 세션스토리지에서 토큰을 가져와 request headers에 실어보낸다
- 로그아웃 시 세션스토리지에서 토큰을 삭제한다
세션스토리지를 활용하면 프론트엔드 서버에서 로그아웃을 세션스토리지에서 토큰 삭제를 통해서 구현한다.
백앤드서버에서 로그아웃을 구현하려면 어떻게 해야할까?
정답은 세션스토리지를 백앤드 서버가 접근할 수 있는 곳으로 옮기는 것이고 대표적인 key-value nosql 데이터베이스인 redis를 통해서 구현가능하다.
redis는 value에 해당하는 key를 할당해서 key를 통해서 value에 접근할 수 있다. 이제 프론트엔드는 세션스토리지 대신 자바스크립트 객체를 redis에 저장한다. redis를 통한 로그인 로그아웃 과정을 알아보자
- 로그인에 필요한 데이터를 백앤드 서버로 보낸다
- 백엔드 서버로 들어온 로그인정보를 확인해서 데이터베이스 정보와 일치하면 토큰을 생성한다
- redis에 랜덤한 key를 만들고, 그 key에 토큰을 붙혀서 저장시킨다
- 로그인에 성공해서 토큰을 발급할 때 직접 클라이언트에게 토큰을 주지않고 세션스토리지 대용으로 사용하는 redis에 랜덤이름의 key(uuid) + 토큰을 넣고, uuid와 토큰을 리턴한다. 클라이언트는 받은 토큰을 세션스토리지에 저장하지 않고 redis에서 꺼내쓴다.
- 클라이언트는 권한이 필요한 api를 요청할 때는 redis에 로그인 할 때 받은 key를 통해서 토큰을 가져와서 사용한다
- 그리고 로그아웃 api를 호출할 때 request body로 로그인 시 받은 key(uuid)와 토큰을 보낸다
- 백앤드 서버에선느 토큰을 decode해서 expiration date를 현시각으로 바꿔서 만료토큰을 만든다
- 클라이언트에게서 받은 key로 redis에 있는 로그인 토큰을 만료토큰으로 치환한다
이제 클라이언트가 로그아웃을 한 상태에서 redis에서 토큰을 가져와서 권한이 필요한 api를 호출하면 만료토큰을 사용할 것이고, 백앤드서버에서 만료토큰은 유효성검사에서 걸러지게 된다.
게시판 리스트 표출 API, 게시물 리스트 표출 API를 재활용해서 가장 각각의 게시판에 가장 최근 게시물을 가져오는 대시보드 API를 만들었다
@board_app.route("/recent-articles", methods=["GET"], endpoint='get_recent_board_article')
@login_required
@validate_params(
[1] Param('offset', GET, int, required=False),
[2] Param('limit', GET, int, required=False),
)
def get_recent_board_article(*args):
[3] board_info = {
'offset': 0,
'limit': None
}
[4] board_service = BoardService()
[5] board_list = board_service.get_board_list(board_info)
[6] for board in board_list:
board_service = BoardService()
[7] article_info = {
'board_id': board['id'],
'offset': args[0] if args[0] else 0,
'limit': args[1] if args[1] else 2
}
[8] article_list = board_service.get_article_list(article_info)
board['recent_article_list'] = article_list
return jsonify({'dashboard': board_list})
[1][2] 기존에 만든 게시판 리스트 표출, 게시물 리스트 표출 함수는 전부 offset과 limit 값이 인자로 필요하기 때문에 기준을 쿼리파라미터로 받는다
[3] 게시판의 경우 offset과 limit을 통해서 페이지네이션을 할 필요가 없다고 판단하여 값을 처음부터 지정해주도록 한다
[4][5] 만들어 두었던 게시판 리스트 표출 함수를 호출해서 값을 가져온다
[6] 하나의 게시판안에 있는 게시물을 가져와야 하기 때문에 for문을 돌린다
[7] 게시물을 가져오기 위해서 필요한 파라미터를 게시물 리스트 표출 함수의 파라미터로 넘긴다(게시물을 가져오기 위해서 게시판 아이디, offset, limit이 필요함)
[8] 각각의 게시판에 게시물 리스트를 가져와서 게시판 정보에 새로운 key를 부여해서 value를 넣는다