Github
https://github.com/osamhack2021/app_web_IoT_UMCS_Team60/tree/main/WEB/Server
Goal
간부(관리자)의 병사(사용자) 관리 체계 벡앤드 구축
User (App)
- 병사는 스마트폰 어플리케이션(Flutter)을 사용하여 체계 접속
- 시설 출입 시마다 서버로 출입 정보를 넘겨주므로 병사 정보는 앱에 저장되어 있어야 함초기 로그인 시 Json Web Token을 발급하여 App에 저장
Manager (Web)
- 웹 브라우저(Vue.js)을 통해 체계 접속
- 모니터링 페이지 실시간 업데이트 및 실시간 알림을 받을 수 있게 해야 함
- websocket protocol을 사용하여 양방향 실시간 통신
- 지정된 날짜의 지정된 생활관에 한에서만 근무자 권한을 얻을 수 있고, 특정 생활관의 근무자 권한이 있어야만 특정 생활관 관리가 가능하게 해야 함
- socket.io의 namespace, room 개념을 이용
- 그 외 정보들을 추가, 조회, 수정, 삭제 할 수 있어야 함
- 간부 로그인 시 passport를 사용하여 session 유지, 로그인 되어있는지 체크 한 후 CRUD 가능하도록 구현
Result
Database
데이터베이스는 MySQL을, Docker Container 위에서 실행하도록 하였다.
Docker Container에서 MySQL을 생성, 실행시키는 작업은 인프라 담당 팀원분께서 작업해주셨고, 이후 DB 설계는 직접 진행하였다.
SQL 생성문 코드 보기/숨기기
-- beacon Table Create SQL
CREATE TABLE beacon
(
`id` VARCHAR(45) NOT NULL,
`doom_id` INT NULL,
`doomroom_id` INT NULL,
`doomfacility_id` INT NULL,
`outside_facility_id` INT NULL,
CONSTRAINT PK_beacon PRIMARY KEY (id)
);
-- doom Table Create SQL
CREATE TABLE doom
(
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(45) NULL,
`beacon_id` VARCHAR(45) NULL,
`current_count` INT NULL,
CONSTRAINT PK_doom PRIMARY KEY (id)
);
ALTER TABLE doom
ADD CONSTRAINT FK_doom_beacon_id_beacon_id FOREIGN KEY (beacon_id)
REFERENCES beacon (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
-- doomroom Table Create SQL
CREATE TABLE doomroom
(
`id` INT NOT NULL AUTO_INCREMENT,
`beacon_id` VARCHAR(45) NULL,
`doom_id` INT NULL,
`floor` INT NULL,
`name` VARCHAR(45) NULL,
`total_count` INT NULL,
`current_count` INT NULL,
CONSTRAINT PK_doomroom PRIMARY KEY (id)
);
ALTER TABLE doomroom
ADD CONSTRAINT FK_doomroom_beacon_id_beacon_id FOREIGN KEY (beacon_id)
REFERENCES beacon (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
ALTER TABLE doomroom
ADD CONSTRAINT FK_doomroom_doom_id_doom_id FOREIGN KEY (doom_id)
REFERENCES doom (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
-- outside_facility Table Create SQL
CREATE TABLE outside_facility
(
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(45) NULL,
`beacon_id` VARCHAR(45) NULL,
`current_count` INT NULL,
CONSTRAINT PK_outside_facility PRIMARY KEY (id)
);
ALTER TABLE outside_facility
ADD CONSTRAINT FK_outside_facility_beacon_id_beacon_id FOREIGN KEY (beacon_id)
REFERENCES beacon (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
-- manager Table Create SQL
CREATE TABLE manager
(
`tag` VARCHAR(45) NOT NULL,
`name` VARCHAR(45) NULL,
`rank` VARCHAR(45) NULL,
`auth` INT NULL,
`salt` TEXT NULL,
`enc_pwd` TEXT NULL,
CONSTRAINT PK_ PRIMARY KEY (tag)
);
-- user Table Create SQL
CREATE TABLE user
(
`tag` VARCHAR(45) NOT NULL,
`name` VARCHAR(45) NULL,
`rank` VARCHAR(45) NULL,
`room_id` INT NULL,
`doom_id` INT NULL,
`salt` TEXT NULL,
`enc_pwd` TEXT NULL,
CONSTRAINT PK_USER PRIMARY KEY (tag)
);
ALTER TABLE user
ADD CONSTRAINT FK_user_doom_id_doom_id FOREIGN KEY (doom_id)
REFERENCES doom (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
ALTER TABLE user
ADD CONSTRAINT FK_user_room_id_doomroom_id FOREIGN KEY (room_id)
REFERENCES doomroom (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
-- doomfacility Table Create SQL
CREATE TABLE doomfacility
(
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(45) NULL,
`beacon_id` VARCHAR(45) NULL,
`doom_id` INT NULL,
`user_number` INT NULL,
`floor` INT NULL,
`current_count` INT NULL,
CONSTRAINT PK_doomfacility PRIMARY KEY (id)
);
ALTER TABLE doomfacility
ADD CONSTRAINT FK_doomfacility_beacon_id_beacon_id FOREIGN KEY (beacon_id)
REFERENCES beacon (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
ALTER TABLE doomfacility
ADD CONSTRAINT FK_doomfacility_doom_id_doom_id FOREIGN KEY (doom_id)
REFERENCES doom (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
-- access_record Table Create SQL
CREATE TABLE access_record
(
`id` INT NOT NULL AUTO_INCREMENT,
`user_tag` VARCHAR(45) NOT NULL,
`beacon_id` VARCHAR(45) NULL,
`in_time` DATETIME NULL,
`out_time` DATETIME NULL,
CONSTRAINT PK_access_record PRIMARY KEY (id)
);
ALTER TABLE access_record
ADD CONSTRAINT FK_access_record_user_tag_user_tag FOREIGN KEY (user_tag)
REFERENCES user (tag) ON DELETE RESTRICT ON UPDATE RESTRICT;
ALTER TABLE access_record
ADD CONSTRAINT FK_access_record_beacon_id_beacon_id FOREIGN KEY (beacon_id)
REFERENCES beacon (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
-- watchman Table Create SQL
CREATE TABLE watchman
(
`id` INT NOT NULL AUTO_INCREMENT,
`manager_tags` VARCHAR(45) NULL,
`charge_doom` INT NULL,
`responsible_date` DATE NULL,
`shift` TEXT NULL,
CONSTRAINT PK_watchman PRIMARY KEY (id)
);
ALTER TABLE watchman
ADD CONSTRAINT FK_watchman_manager_tags_manager_tag FOREIGN KEY (manager_tags)
REFERENCES manager (tag) ON DELETE RESTRICT ON UPDATE RESTRICT;
ALTER TABLE watchman
ADD CONSTRAINT FK_watchman_charge_doom_doom_id FOREIGN KEY (charge_doom)
REFERENCES doom (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
-- facility_request Table Create SQL
CREATE TABLE facility_request
(
`id` INT NOT NULL AUTO_INCREMENT,
`user_tag` VARCHAR(45) NOT NULL,
`facility_id` INT NULL,
`request_time` DATETIME NULL,
`desired_time` TIME NULL,
`permission` tinyint(1) NULL,
`manager_tag` VARCHAR(45) NOT NULL,
`description` TEXT NULL,
CONSTRAINT PK_facility_request PRIMARY KEY (id)
);
ALTER TABLE facility_request
ADD CONSTRAINT FK_facility_request_facility_id_doomfacility_id FOREIGN KEY (facility_id)
REFERENCES doomfacility (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
ALTER TABLE facility_request
ADD CONSTRAINT FK_facility_request_user_tag_user_tag FOREIGN KEY (user_tag)
REFERENCES user (tag) ON DELETE RESTRICT ON UPDATE RESTRICT;
ALTER TABLE facility_request
ADD CONSTRAINT FK_facility_request_manager_tag_user_tag FOREIGN KEY (manager_tag)
REFERENCES manager (tag) ON DELETE RESTRICT ON UPDATE RESTRICT;
-- anomaly Table Create SQL
CREATE TABLE anomaly
(
`id` INT NOT NULL AUTO_INCREMENT,
`user_tag` VARCHAR(45) NOT NULL,
`temperature` FLOAT(7,2) NULL,
`details` TEXT NULL,
`reported_time` DATETIME NULL,
CONSTRAINT PK_anomaly PRIMARY KEY (id)
);
ALTER TABLE anomaly
ADD CONSTRAINT FK_anomaly_user_tag_user_tag FOREIGN KEY (user_tag)
REFERENCES user (tag) ON DELETE RESTRICT ON UPDATE RESTRICT;
-- timetable Table Create SQL
CREATE TABLE timetable
(
`id` INT NOT NULL AUTO_INCREMENT,
`doom_id` INT NULL,
`room_id` INT NOT NULL,
`facility_id` INT NULL,
`start_time` DATETIME NULL,
`end_time` DATETIME NULL,
CONSTRAINT PK_timetable PRIMARY KEY (id)
);
ALTER TABLE timetable
ADD CONSTRAINT FK_timetable_facility_id_doomfacility_id FOREIGN KEY (facility_id)
REFERENCES doomfacility (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
ALTER TABLE timetable
ADD CONSTRAINT FK_timetable_room_id_doomroom_id FOREIGN KEY (room_id)
REFERENCES doomroom (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
ALTER TABLE timetable
ADD CONSTRAINT FK_timetable_doom_id_doom_id FOREIGN KEY (doom_id)
REFERENCES doom (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
-- outside_request Table Create SQL
CREATE TABLE outside_request
(
`id` INT NOT NULL AUTO_INCREMENT,
`user_tag` VARCHAR(45) NOT NULL,
`outside_id` INT NOT NULL,
`request_time` DATETIME NULL,
`permission` tinyint(1) NULL,
`description` TEXT NULL,
`manager_tag` VARCHAR(45) NOT NULL,
CONSTRAINT PK_outside_request PRIMARY KEY (id)
);
ALTER TABLE outside_request
ADD CONSTRAINT FK_outside_request_outside_id_outside_facility_id FOREIGN KEY (outside_id)
REFERENCES outside_facility (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
ALTER TABLE outside_request
ADD CONSTRAINT FK_outside_request_user_tag_user_tag FOREIGN KEY (user_tag)
REFERENCES user (tag) ON DELETE RESTRICT ON UPDATE RESTRICT;
ALTER TABLE outside_request
ADD CONSTRAINT FK_outside_request_manager_tag_tag FOREIGN KEY (manager_tag)
REFERENCES manager (tag) ON DELETE RESTRICT ON UPDATE RESTRICT;
ALTER TABLE beacon
ADD CONSTRAINT FK_beacon_doom_id_id FOREIGN KEY (doom_id)
REFERENCES doom (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
ALTER TABLE beacon
ADD CONSTRAINT FK_beacon_doomroom_id_id FOREIGN KEY (doomroom_id)
REFERENCES doomroom (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
ALTER TABLE beacon
ADD CONSTRAINT FK_beacon_doomfacility_id_id FOREIGN KEY (doomfacility_id)
REFERENCES doomfacility (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
ALTER TABLE beacon
ADD CONSTRAINT FK_beacon_outside_facility_id_id FOREIGN KEY (outside_facility_id)
REFERENCES outside_facility (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
CREATE TABLE cohort_status
(
`id` INT NOT NULL AUTO_INCREMENT,
`isCohort` tinyint(1) NOT NULL,
`time` DATETIME NULL,
CONSTRAINT PK_cohort_status PRIMARY KEY (id)
);
database와 서버를 연동시키기 위해 mysql
모듈을 사용하였다.
// database.js
const mysql = require('mysql2');
module.exports = () => {
return {
init: () => {
return mysql.createConnection({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME
})
},
db_open: (con) => {
con.connect()
}
}
};
// sync connection example
const dbModule = require(`./database`)();
const dbConnection = dbModule.init();
dbModule.db_open(dbConnection);
dbConnection.query(sql, [], callback(err, rows));
database.js
와 같이 동기식 연결을 처음에는 사용했지만, 코드를 작성할 수록 콜백지옥에 빠지는 바람에 아래의 databasePromise.js
같이 비동기식 연결로 바꾸었다.
// databasePrmoise.js
const mysql = require('mysql2');
const pool = mysql.createPool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME
});
module.exports = pool.promise();
// async connection example
const dbPromiseConnection = require(`./databasePromise`);
await dbPromiseConnection.query(sql, []);
간부 회원가입
간부 회원가입은 HTTP POST Method로 가능하다.
// routes/api/managerRouter.js
const router = require('express').Router();
const managerAuth = require(`../../controllers/managerAuth`);
router.post('/register', managerAuth.register, (req, res) => {
if(req.code)
res.status(400).json({
code: req.code,
msg: req.msg,
data: {
tag: req.data.tag,
}
});
else
res.status(200).json({
code: 1,
msg: "success",
data: req.data,
});
});
클라이언트로부터 /api/manager/register
경로로 POST 요청이 온다면 managerAuth
컨트롤러에서 회원가입 처리 후 결과를 보내준다.
// controllers/managerAuth.js
const { pw2enc } = require(`../middleware/auth`);
const register = (req, res, next) => {
var msg = {4: 'db_error', 2: 'duplicate_id'};
var { password, ...data } = req.body; // password를 제외한 req.body를 data로 넘겨줌
req.data = data;
var sql = 'SELECT * FROM manager WHERE tag=?';
dbConnection.query(sql, [req.body.tag], (err, rows) => {
if(err) { // db error
req.code = 4;
req.msg = msg[req.code];
return next(err)
}
if(rows[0]) { // 신규 가입 tag 중복 시
req.code = 2;
req.msg = msg[req.code];
return next();
}
const crypto = pw2enc(req.body.password);
sql = "INSERT INTO manager VALUES (?, ?, ?, ?, ?, ?)";
dbConnection.query(sql, [req.body.tag, req.body.name, req.body.rank, req.body.auth, crypto.salt, crypto.pwEncrypted], (err, rows) => {
if(err) return next(err)
return next();
});
});
};
module.exports = {
register
}
password는 crypto
모듈을 사용하여 단방향 암호화한 후, 데이터베이스에 삽입한다.
// middleware/auth.js
const crypto = require('crypto');
exports.pw2enc = (pw, salt = crypto.randomBytes(64).toString('hex')) => {
var pwEncrypted = crypto.pbkdf2Sync(pw, salt, 100000, 64, 'sha512').toString('hex');
return { salt, pwEncrypted };
}
간부 로그인
passport
미들웨어를 사용하여 session login을 구현하였다.
우선 서버에 session login을 위한 미들웨어들을 설정해준다. 세션을 서버에 파일로 저장하기 위해 session-file-store
미들웨어를, 인증 실패 에러코드를 넘겨주기 위해 flash
미들웨어를 사용한다.
// app.js
const cookieParser = require('cookie-parser');
const session = require('express-session');
const passport = require('passport');
const flash = require('connect-flash');
const FileStore = require("session-file-store")(session);
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(bodyParser.urlencoded({ extended: true }));
app.use(session({
key: 'express.sid',
secret: process.env.COOKIE_SECRET,
saveUninitialized: true,
resave: true,
cookie: {
httpOnly: false,
secure: false
},
store: new FileStore({logFn: function(){}}),
}));
app.use(passport.initialize());
app.use(passport.session());
app.use(flash());
const managerPassportConfig = require('./config/managerPassport');
managerPassportConfig();
passport local strategy를 설정해준다. 군번이 중복되거나 데이터베이스 연결 오류가 생기면 에러코드를 flash로 넘겨준다.
session에는 군번만 저장하고, deserialize할 때마다 db에서 조회하도록 하였다.
// config/managerPassport.js
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const { pw2enc } = require(`../middleware/auth`);
const dbModule = require(`../database`)();
const dbConnection = dbModule.init();
dbModule.db_open(dbConnection);
const LocalStrategyOption = {
usernameField: "tag",
passwordField: "password",
passReqToCallback : true
};
// 관리자 로그인 요청 시 실행
function localVerify(req, tag, password, done) {
var sql = 'SELECT * FROM manager WHERE tag=?';
dbConnection.query(sql, [tag], (err, rows) => {
if(err) // db error
return done(null, false, req.flash('code', 4));
var managerInfo = rows[0];
if(!managerInfo) // tag 불일치
return done(null, false, req.flash('code', 2));
// 요청 온 password를 db에 저장되어 있던 key(salt)로 암호화
var pwEncrypted = pw2enc(password, managerInfo.salt).pwEncrypted;
if(pwEncrypted !== managerInfo.enc_pwd) // 암호화 한 요청 password와 db의 암호화된 pw가 불일치하면
return done(null, false, req.flash('code', 3));
return done(null, managerInfo);
});
}
module.exports = () => {
passport.use('managerLocal', new LocalStrategy(LocalStrategyOption, localVerify));
passport.serializeUser((user, done) => {
done(null, user.tag);
});
passport.deserializeUser((tag, done) => {
var sql = 'SELECT * FROM manager WHERE tag=?';
dbConnection.query(sql, [tag], (err, rows) => {
return done(null, rows[0]);
});
});
}
클라이언트로부터 /api/manager/login
경로로 POST 요청이 온다면 managerAuth
컨트롤러에서 passport 인증 처리 후, 결과를 보내준다.
// routes/api/managerRouter.js
const router = require('express').Router();
const managerAuth = require(`../../controllers/managerAuth`);
router.post('/login', managerAuth.login, (req, res) => {
if(req.code)
res.status(400).json({
code: req.code,
msg: req.msg,
data: {
tag: req.body.tag
}
});
else {
res.status(200).json({
code: 1,
msg: "success",
data: req.data,
});
}
});
// controllers/managerAuth.js
const passport = require('passport');
const login = (req, res, next) => {
const msg = {4: 'db_error', 5: 'authenticate_error', 2: 'cannot_find_id', 3: 'wrong_password'};
passport.authenticate('managerLocal', {failureFlash: true}, (err, manager) => {
if(err) { // db error
req.code = 4;
req.msg = msg[req.code];
return next(err)
}
if(!manager) { // 로그인 실패 시 manager가 비어있음
req.code = req.flash('code')[0]; // managerPassport의 localVerify에서 넘겨받은 flash
req.msg = msg[req.code];
return next();
}
req.login(manager, () => { // 로그인 성공 시
const { salt, enc_pwd, ...payload } = manager; // 비밀번호 관련 정보를 제외하고 payload에 저장
req.data = payload;
return next();
});
})(req, res, next);
};
병사 회원가입
병사 회원가입 또한 HTTP POST Method로 가능하다.
// routes/api/userRouter.js
const userAuth = require(`../../controllers/userAuth`);
router.post('/register', userAuth.register, (req, res) => {
if(req.code)
res.status(400).json({
code: req.code,
msg: req.msg,
data: {
tag: req.data.tag,
}
});
else
res.status(200).json({
code: 1,
msg: "success",
data: req.data,
});
});
클라이언트로부터 /api/user/register
경로로 POST 요청이 온다면 userAuth
컨트롤러에서 회원가입 처리 후 결과를 보내준다.
// controllers/userAuth.js
const { pw2enc } = require(`../middleware/auth`);
const register = (req, res, next) => {
var msg = {4: 'db_error', 2: 'duplicate_id', 3: 'wrong_facility_ids'};
var { password, ...data } = req.body; // password를 제외한 req.body를 data로 넘겨줌
req.data = data;
var sql = 'SELECT * FROM user WHERE tag=?';
dbConnection.query(sql, [req.body.tag], (err, rows) => {
if(err) { // db error
req.code = 4;
req.msg = msg[req.code];
return next(err)
}
if(rows[0]) { // 신규 가입자 tag 중복
req.code = 2;
req.msg = msg[req.code];
return next();
}
const crypto = pw2enc(req.body.password);
sql = "INSERT INTO user VALUES (?, ?, ?, ?, ?, ?, ?)";
dbConnection.query(sql, [req.body.tag, req.body.name, req.body.rank, req.body.room_id, req.body.doom_id, crypto.salt, crypto.pwEncrypted], (err, rows) => {
if(err) { // room_id, doom_id 등 등록되지 않은 시설 id가 들어옴
req.code = 3;
req.msg = msg[req.code];
return next();
}
return next();
});
});
};
병사 로그인
클라이언트로부터 /api/user/login
경로로 POST 요청이 온다면 로그인 후 session을 유지하는 것이 아닌, jwt를 반환해주어야 한다.
jwt로 passport 인증 가능하도록 jwt strategy를 작성하였다.
// app.js
const userPassportConfig = require('./config/userPassport');
userPassportConfig();
// config/userPassport.js
const passport = require('passport');
const JWTStrategy = require('passport-jwt').Strategy;
const { ExtractJwt } = require('passport-jwt');
const LocalStrategy = require('passport-local').Strategy;
const { pw2enc } = require(`../middleware/auth`);
const dbModule = require(`../database`)();
const dbConnection = dbModule.init();
dbModule.db_open(dbConnection);
const LocalStrategyOption = {
usernameField: "tag",
passwordField: "password",
passReqToCallback : true
};
// 사용자 로그인 요청 시 실행
function localVerify(req, tag, password, done) {
var sql = 'SELECT * FROM user WHERE tag=?';
dbConnection.query(sql, [tag], (err, rows) => {
if(err) // db error
return done(null, false, req.flash('code', 4));
var userInfo = rows[0];
if(!userInfo) // tag 불일치
return done(null, false, req.flash('code', 2));
// 요청 온 password를 암호화 key(salt)로 암호화 하여 pwEncrypted에 저장
var pwEncrypted = pw2enc(password, userInfo.salt).pwEncrypted;
if(pwEncrypted !== userInfo.enc_pwd) // 암호화 한 요청 password와 db의 암호화된 pw가 불일치하면
return done(null, false, req.flash('code', 3));
return done(null, userInfo);
})
}
const jwtStrategyOption = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
};
function jwtVerift(payload, done) {
var sql = 'SELECT * FROM user WHERE tag=?';
dbConnection.query(sql, [payload.tag], (err, rows) => {
if(err) // db error
return done(null, false, req.flash('code', 4));
var userInfo = rows[0];
if(!userInfo) // jwt 비유효
return done(null, false, req.flash('message', 2));
return done(null, userInfo);
});
}
module.exports = () => {
passport.use('userLocal', new LocalStrategy(LocalStrategyOption, localVerify));
passport.use(new JWTStrategy(jwtStrategyOption, jwtVerift));
}
로그인 요청 시 앱에서 저장할 jwt를 HTTP Header에 넣어 반환해준다.
// routes/api/userRouter.js
const router = require('express').Router();
const userAuth = require(`../../controllers/userAuth`);
router.post('/login', userAuth.login, (req, res) => {
if(req.code)
res.status(400).json({
code: req.code,
msg: req.msg,
data: {
tag: req.body.tag
}
});
else
res.setHeader("Access-Control-Expose-Headers", "*").setHeader('Authorization', 'Bearer '+ req.token).status(200).json({
code: 1,
msg: "success",
data : {
...req.data,
jwt:req.token
}
});
});
// controllers/userAuth.js
const passport = require('passport');
const jwt = require('jsonwebtoken');
const { pw2enc } = require(`../middleware/auth`);
// tag, pw로 로그인
const login = (req, res, next) => {
const msg = {4: 'db_error', 5: 'authenticate_error', 2: 'cannot_find_id', 3: 'wrong_password'};
passport.authenticate('userLocal', { session: false }, (err, user) => {
if(err) { // 인증 error
req.code = 4;
req.msg = msg[req.code];
return next();
}
if(!user) { // 로그인 실패 시 user가 비어있음
req.code = req.flash('code')[0]; // managerPassport의 localVerify에서 넘겨받은 flash
req.msg = msg[req.code];
return next();
}
else { // 로그인 성공
req.login(user, { session: false }, () => {
const { salt, enc_pwd, ...payload } = user; // 비밀번호 관련 정보를 제외하고 payload에 저장
const token = jwt.sign( // jwt 생성
payload, // user info
process.env.JWT_SECRET, // secret key
{
expiresIn: "365d"
} // option
);
req.token = token;
req.data = payload;
next();
});
}
})(req, res, next);
};
// jwt로 로그인
const jwtLogin = (req, res, next) => {
const msg = {2: 'token_error', 4: 'db_error'};
passport.authenticate("jwt", { session: false }, (err, user) => {
if(err) { // 인증 error
req.code = 2;
req.msg = msg[req.code];
return next();
}
if(!user) { // 로그인 실패 시 user가 비어있음
req.code = req.flash('code')[0]; // managerPassport의 jwtVerift에서 넘겨받은 flash
req.msg = msg[req.code];
return next();
}
else { // 로그인 성공
req.login(user, { session: false }, () => {
const { salt, enc_pwd, ...payload } = user; // 비밀번호 관련 정보를 제외하고 payload에 저장
req.data = payload;
next();
});
}
})(req, res, next);
}
module.exports = {
login,
jwtLogin,
}
근무자 추가/조회
근무자는 당직사관 개념을 생각하면 이해하기 쉽다. 특정 생활관을 지정된 날짜에 관리할 수 있는 간부에게 주어지는 권한으로, 근무자로 지정되어야만 모니터링 조회 및 실시간 알림을 받을 수 있다.
POST 요청으로 근무자 추가, GET 요청으로 날짜별/생활관별 근무자 조회가 가능하도록 구현하였다.
소켓 통신 구성
병사와 관리자(간부) 간 실시간 통신을 위하여 websocket 프로토콜을 Node.js 서버에서 쉽게 사용할 수 있는 Socket.io
모듈을 사용하였다.
이때, 병사가 소켓을 보내면 병사 소속 생활관 근무자에게만 소켓이 전송되어야 하므로, Socket.io의 namespace
와 room
개념을 사용하였다.
소켓구성사진
우선 병사와 근무자(간부)를 user와 manager로 namespace를 나누어준다.
그리고 생활관(근무지)별로 room을 나누어 병사는 소속 생활관으로, 간부는 당일 담당 생활관(근무지)에 접속한다.
// app.js
const express = require('express');
const app = express();
const server = require('http').createServer(app);
const session = require('express-session');
// socket connection
const io = require('./socket')(server, session);
// socket.js
module.exports = (server, session) => {
const io = require('socket.io')(server);
var userio = io.of('/user'),
managerio = io.of('/manager');
return io;
};
병사 소켓 접속
병사 namespace로부터 socket connection 요청이 들어오면,
- request header의
authorization
으로 넘어온 jwt를 확인하여 병사를 식별한다. - 식별한 데이터로 소속 생활관을 확인하고 해당 생활관 room으로 연결한다.
// socket.js
userio.on('connection', (socket) => {
try { // jwt가 header로 왔는지 확인하여 user 인증
var user = decodeToken(socket.handshake.headers.authorization.split('Bearer ')[1]); // jwt 해독
socket.join(user.doom_id); // 소속 생활관 room으로 연결
console.log('user connected to socket:', user);
}
catch { // 인증 실패
socket.disconnect(0);
}
}
위 이미지는 서버 테스트 페이지로, 병사 군번 입력 후 제출시 자동으로 JWT를 생성하고 이를 header로 넣어 소켓 연결을 할 수 있도록 하였다.
간부 소켓 접속
간부 namespace로부터 socket connection 요청이 들어오면,
- passport 모듈로 요청한 간부 계정를 확인한다.
- 해당 간부가 오늘 근무자인지 확인한다.
- 오늘 근무지(담당 생활관) room으로 연결한다.
passport
와 socket.io
의 연동을 위해 위해 passport.socketio
모듈을 사용하였다.
// app.js
const express = require('express');
const app = express();
const server = require('http').createServer(app);
const session = require('express-session');
const FileStore = require("session-file-store")(session);
app.use(session({
key: 'express.sid',
secret: process.env.COOKIE_SECRET,
saveUninitialized: true,
resave: true,
cookie: {
httpOnly: false,
secure: false
},
store: new FileStore({logFn: function(){}}),
}));
// socket connection
const io = require('./socket')(server, session);
// socket.js
const passportSocketIo = require("passport.socketio");
const dbPromiseConnection = require(`./databasePromise`);
module.exports = (server, session) => {
const io = require('socket.io')(server);
const FileStore = require("session-file-store")(session);
io.use(passportSocketIo.authorize({
cookieParser: require('cookie-parser'),
key: 'express.sid',
secret: process.env.COOKIE_SECRET,
store: new FileStore({logFn: function(){}}),
success: onAuthorizeSuccess,
fail: onAuthorizeFail,
}));
function onAuthorizeSuccess(data, accept){
accept(null, true);
}
function onAuthorizeFail(data, message, error, accept){
accept(null, false);
}
managerio.on('connection', async (socket) => {
try { // 연결 요청 시 관리자 인증 과정
var { salt, enc_pwd, ...manager } = socket.request.user;
// 금일 근무가 있는지 확인
var sql = "SELECT * FROM watchman WHERE manager_tags=? AND responsible_date=?";
let [results] = await dbPromiseConnection.query(sql, [manager.tag, nowDate()]);
if(results.length) { // 금일 근무가 있을 시에만 담당 생활관 room에 참여
manager.charge_doom = [];
for(let result of results) {
manager.charge_doom.push(result.charge_doom);
socket.join(result.charge_doom);
}
}
managerio.emit('my_info', manager);
console.log('manager connected to socket:', manager);
}
catch (err){ // 관리자 인증 실패
console.log(err);
socket.disconnect(0);
}
passport.socketio
모듈은 세션을 메모리에 유지하면 세션을 읽지 못한다. 그래서 session-file-store
모듈을 사용하여 세션을 파일로 저장한 것을 app.js
에서 확인할 수 있다.
또한, 담당 생활관 room에 연결하는 것을 확인할 수 있다.
외부시설 이동요청
병사는 일과 후 외부시설로 이동하기 전 당직 근무자에게 이동 승인을 받아야 한다.
이때, 관리자 namespace의 병사 소속 생활관 room으로 소켓을 보내면, 해당 생활관 근무자에게만 소켓이 전달될 수 있다.
소켓을 보내기 전, db에 적절히 데이터를 기록한다.
// socket.js
managerio.on('connection', async (socket) => {
...
socket.on('move_request', async (data) => {
try {
let sql = "INSERT INTO outside_request VALUE (NULL, ?, ?, ?, NULL, ?, NULL, ?)";
let [result] = await dbPromiseConnection.query(sql, [user.tag, data.outside_id, nowDateTime(), data.description, user.socket_id]);
managerio.to(user.doom_id).emit('move_request', {
id: result.insertId,
user_tag: user.tag,
request_time: nowDateTime(),
...data
});
}
catch(err) {
console.log(err);
}
});
...
});
담당 생활관 근무자가 2명이라 2명에게 알림이 가는 것을 확인할 수 있고,
담당 생활관 근무자가 1명이라 1명에게만 알림이 가는 것을 확인할 수 있다.
외부시설 결재완료
이동 결재 알림은 요청을 보낸 병사에게만 가야 한다.
이를 위해 병사 소켓 연결 시 자동으로 생성되는 socket.id
를 db에 저장해두고, 결재완료 소켓을 보낼 때 해당 id에게만 보내도록 하였다.
// socket.js
userio.on('connection', (socket) => {
try { // jwt가 header로 왔는지 확인하여 user 인증
var user = decodeToken(socket.handshake.headers.authorization.split('Bearer ')[1]); // jwt 해독
socket.join(user.doom_id);
user.socket_id = socket.id;
console.log('user connected to socket:', user);
}
catch { // 인증 실패
socket.disconnect(0);
}
}
managerio.on('connection', async (socket) => {
...
socket.on('move_approval', async (data) =>{
try {
let sql = "UPDATE outside_request SET permission=?, manager_tag=? WHERE id=?";
await dbPromiseConnection.query(sql, [data.permission, manager.tag, data.id]);
sql = "SELECT outside_id, request_time, permission, socket_id FROM outside_request WHERE id=?";
let [results] = await dbPromiseConnection.query(sql, [data.id]);
userio.to(results[0].socket_id).emit('move_approval', {id: data.id, ...results[0]});
}
catch(err) {
console.log(err);
}
});
});
이동 감지
마찬가지로 담당 근무자에게만 알림이 가는 것을 확인할 수 있다.
REST API
아래와 같은 기능들을 제공할 수 있도록 구축하였다.
https://soft-bank-3ec.notion.site/API-635f9e6f00224036a50fb18dc7588569
'대회, 프로젝트 > 2021 군장병 해커톤' 카테고리의 다른 글
[2021 군장병 공개SW 온라인 해커톤] 후기 및 회고 (0) | 2021.11.13 |
---|---|
[Vue.js, Node.js] passport.socketio cors issue (0) | 2021.10.14 |
[Vue.js] Socket.io 연결 Issue 해결 (0) | 2021.10.12 |
[Node.js] Socket.IO + JWT + Session (0) | 2021.10.12 |
[Docker / M1 sillicon] MySQL Build Error, 컨테이너 내에서 한글 입력 (0) | 2021.10.09 |
Comment