среда, 9 октября 2013 г.

Авторизация в web-приложениях. Заменяем Spring Security на 100 строк своего кода :)

Есть несколько принципов разработки приложений, вроде бы верных и абстрактно полезных. Эти принципы настолько укоренились в сознании современного программиста, что порой заменяют ему здравый смысл, лишают его способности, которая так ценна в его профессии: способности думать. Вот к примеру:
"Нельзя делать свои велосипеды! Надо использовать проверенные и отлаженные библиотеки!"
Вроде бы верно. Но если посмотреть чуть глубже... Почему так? Адепты "проверенных библиотек" с горящими глазами скажут что-то вроде: "Свои велосипеды писать дольше, их надо отлаживать и их труднее сопровождать". Для "сферического проекта в вакууме" это верно. Но давайте рассмотрим задачу реальную. Я постараюсь поставить условия задачи, которые чаще всего встречались в моей, уже почти 10-летней практике. И покажу решение, которое писать быстрее, проще и легче сопровождать, хотя оно и является классическим "велосипедом" :)


Задача

Мы делаем web приложение. И нам нужно реализовать систему авторизации пользователей для доступа к отдельным страницам нашего приложения. Для определённости, пусть это будет панель администрирования сайта, реализованная на обычных jsp-страницах. Пользователям мы будем предоставлять доступ к отдельным разделам, при чём области доступа могут пересекаться. Например user1 имеет доступ только к старнице user1.jsp, user2 - только к user2.jsp, moderator - к страницам user1.jsp и user2.jsp а admin - ко всем вышеперечисленным а также к secure.jsp. И, напоследок, index.jsp - будет доступна вообще без авторизации. 

Ограничения:
  • никакого специфического кода не должно быть в наших jsp-страницах.
  • никаких "тупиковых" страниц типа 403 access denied
  • настройки держим отдельно от кода
И, самое главное: минимум посторонних библиотек. Если мы пишем админку на 10-50 классов (а именно такие они, админки, в большинстве своём), то подключать и конфигурировать 100500 классов "Spring Чего-То Там" будет долго, тяжело и нафиг не нужно. 

Инструменты

javax.servlet.Filter. И всё. Нам нужно повесить предобработку запросов на определённые (или все) url? Именно для этого и предназначен этот интерфейс. 
Правила доступа пользователей к страницам опишем в обычном txt-файле вот таким образом:

*~~/index.jsp,/
admin~adminSuperPassword~/*
user1~u1pass~/user1.jsp
user2~u2pass~/user2.jsp
moderator~moderpass~/user1.jsp,/user2.jsp

Тут всё просто: каждая строка описывает одного пользователя. Логин, пароль и набор url. Три части правила разделяем символом "~", url разделяем запятыми. *-означает любого пользователя, /*-любой url. 

И, собственно, 100 строк реализации:

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import java.io.*;
import java.util.*;
 
@WebFilter("/*")
public class AuthFilter implements Filter {
 
    private static List<Rule> rules = new ArrayList<Rule>();
    private static final String PARAM_TOKEN = "token";
    private static final String PARAM_LOGIN = "login";
    private static final String PARAM_PASS = "pass";
 
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        BufferedReader res = new BufferedReader(new InputStreamReader(filterConfig.getServletContext().getResourceAsStream("/WEB-INF/classes/users.txt")));
        try {
            String tmp;
            while ((tmp=res.readLine())!=null) {
                String[] strings = tmp.split("~");
                if (strings.length==3) rules.add(new Rule(strings[0], strings[1], strings[2].split(",")));
            }
        } catch (IOException ioe) {
            System.err.println("access rules not loaded!");
            ioe.printStackTrace();
        }
    }
 
    @Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
        String uri = ((HttpServletRequest) req).getRequestURI();
        boolean rez = false;
        for (Rule r : rules) {
            if (r.isApply(uri) && auth((HttpServletRequest)req, r)) {
                rez = true;
                break;
            }
        }
        if (rez) chain.doFilter(req, resp);
        else {
            PrintWriter out = resp.getWriter();
            out.print("<html><body><form method=\"POST\" action=\"" + uri + "\">\n" +
                    "<input type=\"text\" name=\""+PARAM_LOGIN+"\" placeholder=\"Login\"><br/>\n" +
                    "<input type=\"password\" name=\""+PARAM_PASS+"\" placeholder=\"Password\"><br/>\n" +
                    "<input type=\"submit\" value=\"Login\" />\n" +
                    "</form></body></html>");
            out.flush();
            out.close();
        }
    }
 
    @Override
    public void destroy() {}
 
    public boolean auth(HttpServletRequest req, Rule r) {
        if (Rule.USER_ANY.equals(r.user)) return true;
        String reqUser = getStoredUser(req);
        String reqLogin = req.getParameter(PARAM_LOGIN);
        String reqPass = req.getParameter(PARAM_PASS);
        if (reqUser!=null && reqUser.equals(r.user)) {
            return true;
        }
        if(reqLogin!=null && reqPass!=null && r.check(reqLogin, reqPass)) {
            storeUser(req, reqLogin);
            return true;
        }
        return false;
    }
 
    private String getStoredUser(HttpServletRequest req) {
        return (String) req.getSession().getAttribute(PARAM_TOKEN);
    }
 
    private void storeUser(HttpServletRequest req, String user) {
        req.getSession(true).setAttribute(PARAM_TOKEN, user);
    }
 
    private class Rule {
        public static final String USER_ANY = "*";
        public static final String URL_ANY = "/*";
        String user, password;
        String[] access;
 
        private Rule(String user, String password, String[] access) {
            this.user = user;
            this.password = password;
            this.access = access;
        }
 
        public boolean isApply(String uri) {
            for (String a : access) if (URL_ANY.equals(a) || a.equals(uri)) return true;
            return false;
        }
 
        public boolean check(String login, String pass) {
            return USER_ANY.equals(this.user) || (login.equals(this.user) && pass.equals(this.password));
        }
    }
}

Как это работает?

В реализации метода init() мы вычитываем файл users.txt c с нашими настройками доступа. Если планируется добавлять и удалять пользователей часто - перенесите эту логику в метод doFilter(). В методе doFilter() мы перебираем правила (экземпляры вложенного класса Rule) и если данное правило применимо к данному url выполняем метод auth() для этого правила. Если не одно из правил не сработало, выводим пользователю поля для авторизации. Форма авторизации будет отправлена сюда же и снова пройдёт через те же правила. Авторизация идёт в два захода: сначала ищем уже авторизованного пользователя в сессии и проверяем его полномочия, затем пытаемся его авторизовать и сохранить в сессию. Есть необходимость хранить сессии где-то в другом месте? Например, у вас много инстансов с общим Redis? Просто заменяем внутренности методов storeUser() и getStoredUser(). Страшно держать пароли в открытом виде? Sha1 вам в помощь. Класс всего один, маленький, простой, без зависимостей: разобраться сможет любой стажёр. И допилить под свои потребности, если нужно.

Как это подключить? 

Просто положите фильтр в свой проект и заполните правила доступа. Никаких километровых xml-конфигов в стиле прошлого века. На дворе 2013 год. Все адекватные java web-сервера уже реализуют спецификацию Servlet 3.0 и нам пора заканчивать использовать языки разметки текстов вместо языков программирования :)  

2 комментария: