sso.py 5.72 KB
Newer Older
1
2
3
import time
import uuid

4
5
from urllib.parse import urlencode

6
7
import fastapi

8
from authlib.integrations.starlette_client import OAuth, OAuthError
9
10
11
from fastapi import Depends, HTTPException
from fastapi.responses import RedirectResponse
from sqlalchemy.sql import select
12
13
14
from starlette.requests import Request

import aurweb.config
15
16
import aurweb.db

17
from aurweb.l10n import get_translator_for_request
18
from aurweb.schema import Bans, Sessions, Users
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

router = fastapi.APIRouter()

oauth = OAuth()
oauth.register(
    name="sso",
    server_metadata_url=aurweb.config.get("sso", "openid_configuration"),
    client_kwargs={"scope": "openid"},
    client_id=aurweb.config.get("sso", "client_id"),
    client_secret=aurweb.config.get("sso", "client_secret"),
)


@router.get("/sso/login")
async def login(request: Request):
34
35
36
37
38
39
40
    """
    Redirect the user to the SSO provider’s login page.

    We specify prompt=login to force the user to input their credentials even
    if they’re already logged on the SSO. This is less practical, but given AUR
    has the potential to impact many users, better safe than sorry.
    """
41
42
43
44
    redirect_uri = aurweb.config.get("options", "aur_location") + "/sso/authenticate"
    return await oauth.sso.authorize_redirect(request, redirect_uri, prompt="login")


45
46
47
48
49
def is_account_suspended(conn, user_id):
    row = conn.execute(select([Users.c.Suspended]).where(Users.c.ID == user_id)).fetchone()
    return row is not None and bool(row[0])


50
def open_session(request, conn, user_id):
51
52
53
    """
    Create a new user session into the database. Return its SID.
    """
54
    if is_account_suspended(conn, user_id):
55
56
        _ = get_translator_for_request(request)
        raise HTTPException(status_code=403, detail=_('Account suspended'))
57
58
        # TODO This is a terrible message because it could imply the attempt at
        #      logging in just caused the suspension.
59

60
61
62
63
64
65
    sid = uuid.uuid4().hex
    conn.execute(Sessions.insert().values(
        UsersID=user_id,
        SessionID=sid,
        LastUpdateTS=time.time(),
    ))
66
67
68
69
70
71
72

    # Update user’s last login information.
    conn.execute(Users.update()
                      .where(Users.c.ID == user_id)
                      .values(LastLogin=int(time.time()),
                              LastLoginIPAddress=request.client.host))

73
74
75
    return sid


76
77
78
79
80
81
82
83
84
def is_ip_banned(conn, ip):
    """
    Check if an IP is banned. `ip` is a string and may be an IPv4 as well as an
    IPv6, depending on the server’s configuration.
    """
    result = conn.execute(Bans.select().where(Bans.c.IPAddress == ip))
    return result.fetchone() is not None


85
@router.get("/sso/authenticate")
86
87
88
89
90
async def authenticate(request: Request, conn=Depends(aurweb.db.connect)):
    """
    Receive an OpenID Connect ID token, validate it, then process it to create
    an new AUR session.
    """
91
    if is_ip_banned(conn, request.client.host):
92
        _ = get_translator_for_request(request)
93
94
        raise HTTPException(
            status_code=403,
95
96
97
            detail=_('The login form is currently disabled for your IP address, '
                     'probably due to sustained spam attacks. Sorry for the '
                     'inconvenience.'))
98
99
100
101
102
103
104
105
106
107
108
109

    try:
        token = await oauth.sso.authorize_access_token(request)
        user = await oauth.sso.parse_id_token(request, token)
    except OAuthError:
        # Here, most OAuth errors should be caused by forged or expired tokens.
        # Let’s give attackers as little information as possible.
        _ = get_translator_for_request(request)
        raise HTTPException(
            status_code=400,
            detail=_('Bad OAuth token. Please retry logging in from the start.'))

110
111
    sub = user.get("sub")  # this is the SSO account ID in JWT terminology
    if not sub:
112
113
        _ = get_translator_for_request(request)
        raise HTTPException(status_code=400, detail=_("JWT is missing its `sub` field."))
114
115
116
117
118
119

    aur_accounts = conn.execute(select([Users.c.ID]).where(Users.c.SSOAccountID == sub)) \
                       .fetchall()
    if not aur_accounts:
        return "Sorry, we don’t seem to know you Sir " + sub
    elif len(aur_accounts) == 1:
120
        sid = open_session(request, conn, aur_accounts[0][Users.c.ID])
121
122
123
124
        response = RedirectResponse("/")
        # TODO redirect to the referrer
        response.set_cookie(key="AURSID", value=sid, httponly=True,
                            secure=request.url.scheme == "https")
125
126
127
128
129
        if "id_token" in token:
            # We save the id_token for the SSO logout. It’s not too important
            # though, so if we can’t find it, we can live without it.
            response.set_cookie(key="SSO_ID_TOKEN", value=token["id_token"], path="/sso/",
                                httponly=True, secure=request.url.scheme == "https")
130
131
132
133
        return response
    else:
        # We’ve got a severe integrity violation.
        raise Exception("Multiple accounts found for SSO account " + sub)
134
135
136


@router.get("/sso/logout")
137
async def logout(request: Request):
138
139
140
141
142
143
144
145
    """
    Disconnect the user from the SSO provider, potentially affecting every
    other Arch service. AUR logout is performed by `/logout`, before it
    redirects to `/sso/logout`.

    Based on the OpenID Connect Session Management specification:
    https://openid.net/specs/openid-connect-session-1_0.html#RPLogout
    """
146
147
148
149
    id_token = request.cookies.get("SSO_ID_TOKEN")
    if not id_token:
        return RedirectResponse("/")

150
    metadata = await oauth.sso.load_server_metadata()
151
152
153
154
155
    query = urlencode({'post_logout_redirect_uri': aurweb.config.get('options', 'aur_location'),
                       'id_token_hint': id_token})
    response = RedirectResponse(metadata["end_session_endpoint"] + '?' + query)
    response.delete_cookie("SSO_ID_TOKEN", path="/sso/")
    return response