Apache Tomcat Realm 설정
1. Apache Tomcat 설정
1.1. server.xml 파일 변경
1.1.1. Realm 설정
Apache Tomcat 8 이후에는 다음과 같다.
</Realm>
앞에 다음의 내용을 추가한다.
<Realm className="org.apache.catalina.realm.DataSourceRealm" dataSourceName="jdbc/graha" userTable="users.users" userNameCol="user_name" userCredCol="user_pass" userRoleTable="users.user_roles" roleNameCol="role_name" debug="9"> <CredentialHandler className="org.apache.catalina.realm.MessageDigestCredentialHandler" algorithm="SHA-512"/> </Realm>
algorithm 은 java.security.MessageDigest 가 지원하는 것을 선택할 수 있고, 이에 대해서는 MessageDigest 을 참조한다.
이 글을 작성하는 시점에서 지원하는 algorithm 은 다음과 같다.
- MD2
- MD5
- SHA-1
- SHA-256
- SHA-384
- SHA-512
algorithm 은 Basic 혹은 Form Authentication 에서만 의미가 있다 (Digest Authentication 은 클라이언트에서 realm-name 과 패스워드를 MD5로 암호화해서 보내는 방식이다).
Apache Tomcat 7은 다음과 같다.
<Realm className="org.apache.catalina.realm.DataSourceRealm" dataSourceName="jdbc/graha" userTable="users.users" userNameCol="user_name" userCredCol="user_pass" userRoleTable="users.user_roles" roleNameCol="role_name" debug="9" digest="SHA-512" />
Realm 은 여러 개를 정의할 수도 있다.
1.1.2. SingleSignOn 설정
여러 Context 에서 인증정보를 공유하기 위해서는 <Host>
부분에 다음과 같이 추가한다.
<Host> <Valve className="org.apache.catalina.authenticator.SingleSignOn"/> </Host>
여러 Host 에서 안된다는 것에 주의한다.
1.1.3. 서로 다른 Context 사이에서 Session 을 공유하는 방법에 대해서
서로 다른 Context 사이에 Session 을 공유하는 것은 허용되지 않는다.
이를 회피하기 위해서는 다음과 같은 방법 정도를 생각해 볼 수 있겠다.
- org.apache.catalina.session.PersistentManager 를 이용해서 Session 을 파일시스템이나 JDBC 너머의 데이타베이스에 저장하는 방법 (context.xml 의
<Context>
에sessionCookiePath="/"
를 추가) - org.apache.catalina.authenticator.SingleSignOn 과 유사한 것을 구현하는 방법
context.xml 의
<Context>
에crossContext="true"
를 추가하고,request.getSession().getServletContext().getContext("/").setAttribute("member_id", memberId)
와 같이 처리하는 방식은 Context 의 Attribute 는 여러 사용자가 공유한다는 것에 주의한다.
실무에서는 PersistentManager 를 사용하는 것이 일반적이다.
Instance 가 다른 경우에는 PersistentManager 나 Clustering 를 사용한다.
1.2. web.xml 파일 변경
<login-config> <auth-method>FORM</auth-method> <realm-name>Form Authentication</realm-name> <form-login-config> <form-login-page>/WEB-INF/jsp/realm/login.jsp</form-login-page> <form-error-page>/WEB-INF/jsp/realm/error.jsp</form-error-page> </form-login-config> </login-config> <security-constraint> <web-resource-collection> <web-resource-name>Graha Software Suite</web-resource-name> <url-pattern>/graha/*</url-pattern> <http-method>GET</http-method> <http-method>POST</http-method> </web-resource-collection> <auth-constraint> <role-name>graha</role-name> </auth-constraint> </security-constraint> <security-role> <description>Graha User</description> <role-name>graha</role-name> </security-role>
3개의 XML 요소로 구성된다.
- login-config
- security-constraint
- security-role
첫 번째 login-config 는 인증 방법과 로그인 페이지에 대한 정보를, 마지막 security-role 은 Role 정보를 기술한다.
접근에 대한 구체적인 사항은 security-constraint 에 기술한다.
URL 패턴(<url-pattern>
)과 Http Method(<http-method>
), 그리고 접근가능한 Role(<role-name>
) 을 기술한다.
security-constraint 요소는 여러 개 기술해도 된다. 예를 들어 다음과 같은 설정을 추가할 수 있다.
<security-constraint> <web-resource-collection> <web-resource-name>unsupported method</web-resource-name> <url-pattern>/*</url-pattern> <http-method>PROPFIND</http-method> <http-method>PROPPATCH</http-method> <http-method>COPY</http-method> <http-method>MOVE</http-method> <http-method>LOCK</http-method> <http-method>UNLOCK</http-method> <http-method>PUT</http-method> <http-method>HEAD</http-method> <http-method>DELETE</http-method> <http-method>OPTIONS</http-method> <http-method>TRACE</http-method> </web-resource-collection> </security-constraint>
위의 설정은 지원(구현)하지 않은 Http Method 의 접근을 막는다.
만약 Graha Manager 서블릿도 서비스 하고 있다면, 다음의 설정도 추가해야 할 것이다.
<security-constraint> <web-resource-collection> <web-resource-name>Graha Manager</web-resource-name> <url-pattern>/graha-manager/*</url-pattern> <http-method>GET</http-method> <http-method>POST</http-method> </web-resource-collection> <auth-constraint> <role-name>graha-manager</role-name> </auth-constraint> </security-constraint> <security-role> <description>Graha Manager</description> <role-name>graha-manager</role-name> </security-role>
다음과 같이 <role-name>
을 "*" 로 하면,
<security-role>
로 정의된 권한을 1개라도 가지고 있는 사용자 에 대한 접근을 허용한다.
<security-constraint> <web-resource-collection> <web-resource-name>index</web-resource-name> <url-pattern>/graha/gmenu/index.html</url-pattern> <url-pattern>/graha/gmenu/index.xml</url-pattern> <url-pattern>/graha/gmenu/index.xsl</url-pattern> <http-method>GET</http-method> </web-resource-collection> <auth-constraint> <role-name>*</role-name> </auth-constraint> </security-constraint>
1.3. login.jsp / error.jsp / logout.jsp
1.3.1. login.jsp
디자인 요소를 제거한 login.jsp 는 다음과 같다.
<form method="post" action="j_security_check"> <input type="text" name="j_username" title="아이디" /> <input type="password" name="j_password" title="패스워드" /> </form>
<form>
태그의 method, action 속성값과 각 <input>
태그의 name 속성값을 일치시키면 된다.
패스워드(j_password) 항목의 maxlength 속성은 충분히 큰 값으로 하는 것이 좋다. maxlength 속성이 지정되면, maxlength 보다 큰 입력 값은 무시되고, 입력한 값의 뒷부분이 잘렸기 때문에 로그인에 실패하게 된다. 패스워드 항목은 화면에서 입력한 값을 확인할 수 없고, Realm 인증은 디버깅에 제한이 있기 때문에, 이런 하찮은 사유에도 원인을 찾는데 많은 비용이 발생할 가능성이 높기 때문에 주의를 요한다.
AJAX 를 위해서 다음과 같은 헤더를 추가한다.
response.setStatus(javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED);
1.3.2. error.jsp
아이디나 패스워드가 틀렸다는 안내메시지와 로그인창으로 다시 돌아가는 링크정도를 제공하면 된다.
1.3.3. logout.jsp
로그아웃 하고, 적당한 페이지로 이동시키면 된다.
request.logout(); response.sendRedirect("/");
logout.jsp 는 사용자가 직접 접근할 수 있는 경로에 있어야 한다.
1.3.4. 408 에러에 관하여
로그인 페이지를 오래동안 방치했다면 408 에러가 발생하는데, Session 이 없는 경우이다.
내부 구현에 대해서는 org/apache/catalina/authenticator/FormAuthenticator.java 의 saveRequest 메소드를 참조하라. 단, 응용프로그램에서 Form Authentication 에서 사용하는 Session 에 접근할 수는 없다.
Session 이 없는 경우는 다음과 같다.
- 기한이 만료되었다.
- Client IP 가 변경되었다.
기한이 만료된 것은 web.xml 에 다음의 내용을 추가하는 것으로 해결된다.
<session-config> <session-timeout>-1</session-timeout> </session-config>
(모바일과 같이) Client IP 가 변경되는 상황까지 고려하면, 주기적으로 로그인 페이지를 refresh 해야 한다.
1.3.5. 로그인한 사용자의 세션 만료에 관하여
로그인한 사용자의 세션이 만료되는 상황이 있다.
사용자가 1개의 페이지에 오래동안 머물고 있는 경우가 일반적이겠지만, 모바일에서는 Client IP 가 변경될 수도 있다.
만약 사용자가 주기적으로 refresh 해도 되는 성격의 페이지에 머물고 있다면, 그렇게 해도 되겠지만, 큰 의미는 없다.
세션이 만료된 상태에서 사용자가 다른 페이지로 이동하게 되면, 그 페이지 대신 로그인 페이지가 뜨고, 로그인을 하면 사용자가 원래 이동하려고 했던 페이지로 이동할 것이기 때문이다.
문제는 세션이 만료된 상태에서 사용자가 파일 업로드를 포함한 POST 방식의 요청을 하는 경우인데, 해결책은 다음과 같다.
- 사용자가 머물고 있는 페이지에 주기적인 AJAX 요청을 달아서 기한 만료를 막는다(Client IP 가 변경된 상황을 방어할 수는 없다).
- 사용자의 POST 요청을 Javascript 로 가로채거나, 요청을 AJAX 로 처리하면서, 세션이 유효한지 여부를 확인해서 다시 로그인 할 수 있도록 한다.
1.3.6. access log 에 관하여
Tomcat 의 access log 에는 remote user 가 출력되지만, 그 앞에 세운 Apache HTTPD 서버에는 그렇지 않다.
Session 정보를 공유하지 않는 한 그럴 수는 없을 것이다.
1.3.7. 보안에 관하여
Apache Tomcat 은 3개의 인증 방법을 제공한다.
- BASIC
- DIGEST
- Form Based
https 인 경우 어떠한 방식이어도 상관이 없겠지만, http 인 경우 DIGEST 를 사용해야 하며, DIGEST 는 MD5 암호화만 사용할 수 있음에 주의한다.
1.4. 로그 남기기
뭔가 잘 안된다면 /var/lib/tomcat9/conf/logging.properties
파일에 다음의 내용을 추가하고,
로그를 살핀다.
org.apache.catalina.realm.level = ALL org.apache.catalina.realm.useParentHandlers = true org.apache.catalina.authenticator.level = ALL org.apache.catalina.authenticator.useParentHandlers = true
1.5. 설정을 반영하기 위해 Apache Tomcat 서버를 재시작한다.
sudo service tomcat9 restart
2. 데이타베이스에서 작업
Postgresql 을 기준으로 한다.
Postgresql이 없다면, Postgresql 설치 를 참조한다.
2.1. 테이블 생성
graha 및 graha_backup 데이타베이스에 모두 실행한다.
CREATE SCHEMA users; CREATE SEQUENCE users."users$users_id"; create table users.users( users_id integer NOT NULL DEFAULT nextval('users.users$users_id'::regclass), user_name character varying, user_pass character varying, insert_date timestamp with time zone, insert_id character varying(50), insert_ip character varying(15), update_date timestamp with time zone, update_id character varying(50), update_ip character varying(15), CONSTRAINT users_pkey PRIMARY KEY (users_id) ) WITH ( OIDS=FALSE ); COMMENT ON COLUMN users.users.users_id IS '고유번호'; COMMENT ON COLUMN users.users.user_name IS 'user_name'; COMMENT ON COLUMN users.users.user_pass IS 'user_pass'; COMMENT ON COLUMN users.users.insert_date IS '작성일시'; COMMENT ON COLUMN users.users.insert_id IS '작성자ID'; COMMENT ON COLUMN users.users.insert_ip IS '작성자IP'; COMMENT ON COLUMN users.users.update_date IS '최종수정일시'; COMMENT ON COLUMN users.users.update_id IS '최종수정자ID'; COMMENT ON COLUMN users.users.update_ip IS '최종수정자IP'; CREATE SEQUENCE users."user_roles$user_roles_id"; create table users.user_roles( user_roles_id integer NOT NULL DEFAULT nextval('users.user_roles$user_roles_id'::regclass), role_name character varying, user_name character varying, insert_date timestamp with time zone, insert_id character varying(50), insert_ip character varying(15), update_date timestamp with time zone, update_id character varying(50), update_ip character varying(15), CONSTRAINT user_roles_pkey PRIMARY KEY (user_roles_id) ) WITH ( OIDS=FALSE ); COMMENT ON COLUMN users.user_roles.user_roles_id IS '고유번호'; COMMENT ON COLUMN users.user_roles.role_name IS 'role_name'; COMMENT ON COLUMN users.user_roles.user_name IS 'user_name'; COMMENT ON COLUMN users.user_roles.insert_date IS '작성일시'; COMMENT ON COLUMN users.user_roles.insert_id IS '작성자ID'; COMMENT ON COLUMN users.user_roles.insert_ip IS '작성자IP'; COMMENT ON COLUMN users.user_roles.update_date IS '최종수정일시'; COMMENT ON COLUMN users.user_roles.update_id IS '최종수정자ID'; COMMENT ON COLUMN users.user_roles.update_ip IS '최종수정자IP';
2.2. 데이타 추가
graha 및 graha_backup 데이타베이스에 모두 실행한다.
insert into users.users (user_name, user_pass) values ('user', encode(digest('changeit!', 'sha512'), 'hex')); insert into users.user_roles (role_name, user_name) values ('graha', 'user'); insert into users.users (user_name, user_pass) values ('manager', encode(digest('changeit!', 'sha512'), 'hex')); insert into users.user_roles (role_name, user_name) values ('graha', 'manager'); insert into users.user_roles (role_name, user_name) values ('graha-manager', 'manager');
2.3. SHA-512
PostgreSQL 에서 sha-512 를 사용하기 위해서는 pgcrypto 을 추가해야 한다.
CREATE EXTENSION pgcrypto;
PostgreSQL 에서 처리할 여건이 되지 않는다면, 다음과 같이 Apache Tomcat 에서 제공하는 명령어를 사용할 수도 있다.
/usr/share/tomcat9/bin/digest.sh -a sha-512 -s 0 "changeit!"
digest.sh 는 bin 디렉토리 아래에 위치한다.
그마저도 여의치 않은 경우 Graha 응용프로그램과 함께 배포되는 라이브러리를 이용한다.
kr.graha.app.lib.Digest digest = kr.graha.app.lib.Digest.getInstance(); try { out.println(digest.md5("changeit!")); out.println(digest.sha512("changeit!")); } catch (java.security.NoSuchAlgorithmException e) { out.println(kr.graha.helper.LOG.toString(e)); }