Compare commits
6 Commits
master
...
new-positi
Author | SHA1 | Date |
---|---|---|
Leon | ac9a71437f | |
Leon Zhang | bd453990c9 | |
Leon | 82d375b7f4 | |
Leon | c9928c47e5 | |
Leon | c9ad827c8b | |
Leon | 067a559b5a |
|
@ -1,17 +0,0 @@
|
|||
dn: ou=ADLDAP,dc=csclub,dc=internal
|
||||
objectClass: organizationalUnit
|
||||
ou: ADLDAP
|
||||
|
||||
dn: cn=alumni1,ou=ADLDAP,dc=csclub,dc=internal
|
||||
description: One, Alumni
|
||||
givenName: Alumni
|
||||
sn: *One
|
||||
cn: alumni1
|
||||
sAMAccountName: alumni1
|
||||
displayName: alumni1
|
||||
mail: alumni1@alumni.uwaterloo.internal
|
||||
objectClass: mockADUser
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: organizationalPerson
|
||||
objectClass: person
|
||||
objectClass: top
|
|
@ -31,13 +31,13 @@ IMAGE__setup_ldap() {
|
|||
cp .drone/slapd.conf /etc/ldap/slapd.conf
|
||||
cp .drone/ldap.conf /etc/ldap/ldap.conf
|
||||
cp /usr/share/doc/sudo-ldap/schema.OpenLDAP /etc/ldap/schema/sudo.schema
|
||||
cp .drone/{rfc2307bis,csc,mock_ad}.schema /etc/ldap/schema/
|
||||
cp .drone/rfc2307bis.schema /etc/ldap/schema/
|
||||
cp .drone/csc.schema /etc/ldap/schema/
|
||||
chown -R openldap:openldap /etc/ldap/schema/ /var/lib/ldap/ /etc/ldap/
|
||||
sleep 0.5 && service slapd start
|
||||
ldapadd -c -f .drone/data.ldif -Y EXTERNAL -H ldapi:///
|
||||
if [ -z "$CI" ]; then
|
||||
ldapadd -c -f .drone/uwldap_data.ldif -Y EXTERNAL -H ldapi:/// || true
|
||||
ldapadd -c -f .drone/adldap_data.ldif -Y EXTERNAL -H ldapi:/// || true
|
||||
# setup ldapvi for convenience
|
||||
apt install -y --no-install-recommends vim ldapvi
|
||||
grep -q 'export EDITOR' /root/.bashrc || \
|
||||
|
|
|
@ -186,28 +186,3 @@ objectClass: group
|
|||
objectClass: posixGroup
|
||||
cn: office1
|
||||
gidNumber: 20004
|
||||
|
||||
dn: uid=alumni1,ou=People,dc=csclub,dc=internal
|
||||
cn: Alumni One
|
||||
givenName: Alumni
|
||||
sn: One
|
||||
userPassword: {SASL}alumni1@CSCLUB.INTERNAL
|
||||
loginShell: /bin/bash
|
||||
homeDirectory: /users/alumni1
|
||||
uid: alumni1
|
||||
uidNumber: 20005
|
||||
gidNumber: 20005
|
||||
objectClass: top
|
||||
objectClass: account
|
||||
objectClass: posixAccount
|
||||
objectClass: shadowAccount
|
||||
objectClass: member
|
||||
program: Alumni
|
||||
term: w2024
|
||||
|
||||
dn: cn=alumni1,ou=Group,dc=csclub,dc=internal
|
||||
objectClass: top
|
||||
objectClass: group
|
||||
objectClass: posixGroup
|
||||
cn: alumni1
|
||||
gidNumber: 20005
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
# Mock Active Directory Schema
|
||||
|
||||
attributetype ( 1.3.6.1.4.1.70000.1.1.1 NAME 'sAMAccountName'
|
||||
EQUALITY caseIgnoreMatch
|
||||
SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )
|
||||
|
||||
objectclass ( 1.3.6.1.4.1.70000.1.2.1 NAME 'mockADUser'
|
||||
SUP top AUXILIARY
|
||||
MUST ( sAMAccountName ) )
|
|
@ -7,7 +7,6 @@ include /etc/ldap/schema/rfc2307bis.schema
|
|||
include /etc/ldap/schema/inetorgperson.schema
|
||||
include /etc/ldap/schema/sudo.schema
|
||||
include /etc/ldap/schema/csc.schema
|
||||
include /etc/ldap/schema/mock_ad.schema
|
||||
include /etc/ldap/schema/misc.schema
|
||||
|
||||
pidfile /var/run/slapd/slapd.pid
|
||||
|
@ -41,11 +40,6 @@ access to *
|
|||
by dn="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage
|
||||
by * break
|
||||
|
||||
# hide most attributes for alumni in mock UWLDAP
|
||||
access to attrs=cn,sn,givenName,displayName,ou,mail
|
||||
dn.regex="^uid=alumni[^,]+,ou=(Test)?UWLDAP,dc=csclub,dc=internal$"
|
||||
by * none
|
||||
|
||||
# systems committee get full access
|
||||
access to *
|
||||
by dn="cn=ceod,dc=csclub,dc=internal" write
|
||||
|
|
|
@ -106,18 +106,3 @@ objectClass: person
|
|||
objectClass: top
|
||||
uid: exec3
|
||||
mail: exec3@uwaterloo.internal
|
||||
|
||||
dn: uid=alumni1,ou=UWLDAP,dc=csclub,dc=internal
|
||||
displayName: Alumni One
|
||||
givenName: Alumni
|
||||
sn: One
|
||||
cn: Alumni One
|
||||
ou: MAT/Mathematics Computer Science
|
||||
mailLocalAddress: alumni1@uwaterloo.internal
|
||||
objectClass: inetLocalMailRecipient
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: organizationalPerson
|
||||
objectClass: person
|
||||
objectClass: top
|
||||
uid: alumni1
|
||||
mail: alumni1@uwaterloo.internal
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
# If you update this file, please also update the extend-diff-ignore option
|
||||
# in debian/source/options.
|
||||
|
||||
*.key
|
||||
*.gpg
|
||||
*.pgp
|
||||
|
||||
__pycache__/
|
||||
/venv/
|
||||
/dist/
|
||||
|
|
54
PACKAGING.md
54
PACKAGING.md
|
@ -15,10 +15,7 @@ Make sure you are in the `csc-mirror` group too.
|
|||
Use Docker/Podman to avoid screwing up your main system.
|
||||
For example, to create a package for bullseye (replace `podman` with `docker` in all instances below if you're using Docker):
|
||||
```sh
|
||||
podman run -it --name pyceo-packaging -v "$PWD":"$PWD":z -w "$PWD" --security-opt="label=disable" debian:bookworm bash
|
||||
# if disconnected from shell, reconnect with:
|
||||
podman start pyceo-packaging
|
||||
podman exec -it pyceo-packaging bash
|
||||
podman run -it --name pyceo-packaging -v "$PWD":"$PWD":z -w "$PWD" --security-opt="label=disable" debian:bullseye bash
|
||||
```
|
||||
**Important**: Make sure to use a container image for the same distribution which you're packaging.
|
||||
For example, if you're creating a package for bullseye, you should be using the debian:bullseye
|
||||
|
@ -29,39 +26,22 @@ Here are some of the prerequisites you'll need to build the deb files
|
|||
```sh
|
||||
apt update
|
||||
apt install -y devscripts debhelper git-buildpackage vim
|
||||
apt install -y python3-dev python3-venv libkrb5-dev libpq-dev libaugeas0 scdoc # dependencies for building ceo
|
||||
```
|
||||
Make sure to also install all of the packages in the 'Build-Depends' section in debian/control.
|
||||
|
||||
Update VERSION.txt to the next version, and do a git commit (or `dpkg-source --commit`).
|
||||
Update VERSION.txt to the next version, and do a git commit.
|
||||
|
||||
Now run `dch -i` and edit the changelog (update version, add your uploader name/email, add changes).
|
||||
Now run `dch -i` and edit the changelog.
|
||||
|
||||
Now you will build a signed package. Place your key ID after the `-k` argument, e.g.
|
||||
```sh
|
||||
# (pre-requisite) if container doesn't have your gpg key
|
||||
## step 1: export from host/another computer with your keyring
|
||||
gpg --armor --output private.key --export-secret-key <your pgp key's id email>
|
||||
## step 2: import into build container
|
||||
gpg --import private.key
|
||||
## step 3: find your key's public key
|
||||
gpg --list-secret-keys # get key id
|
||||
## step 4: trust ids (before building)
|
||||
gpg --edit <pub key id>
|
||||
gpg> trust # run when gpg editing prompt appears
|
||||
> 5 # "ultimate" trust
|
||||
gpg> save # gpg will report no changes were made, but trust of ids should be changed
|
||||
|
||||
# alternatively, sign with `debsign` after creating unsigned package
|
||||
|
||||
# build (signed) package
|
||||
gbp buildpackage --git-upstream-branch=master -k8E5568ABB0CF96BC367806ED127923BE10DA48DC --lintian-opts --no-lintian
|
||||
```
|
||||
This will create a bunch of files (deb, dsc, tar.gz, etc.) in the parent directory.
|
||||
|
||||
Now do another git commit (since you edited the changelog file).
|
||||
|
||||
To clean the packages (run this after uploading, ie. **do NOT run this if you just finished building**):
|
||||
To clean the packages:
|
||||
```sh
|
||||
rm ../*.{xz,gz,dsc,build,buildinfo,changes,deb}
|
||||
```
|
||||
|
@ -69,30 +49,28 @@ rm ../*.{xz,gz,dsc,build,buildinfo,changes,deb}
|
|||
## Uploading the package
|
||||
Inside the container, go up one directory, and create a tarball with all the package files:
|
||||
```
|
||||
cd .. # within the container, generated files are in the parent directory of your git repo
|
||||
cd ..
|
||||
tar zcvf pyceo.tar.gz *.{xz,gz,dsc,build,buildinfo,changes,deb}
|
||||
```
|
||||
Outside of the container (i.e. on your personal machine), copy the tarball out of the
|
||||
container into your current directory, e.g.
|
||||
```
|
||||
podman cp pyceo-packaging:/home/max/repos/pyceo.tar.gz .
|
||||
# or generally, if you're in the pyceo repo:
|
||||
podman cp pyceo-packaging:$(cd ../ && pwd)/pyceo.tar.gz .
|
||||
```
|
||||
(Replace `/home/max/repos` by the directory in the container with the tarball.)
|
||||
Now upload the tarball to a CSC machine, e.g.
|
||||
```
|
||||
# on "HOST" machine
|
||||
scp pyceo.tar.gz mannitol:~
|
||||
```
|
||||
SSH into that machine and extract the tarball into a separate directory:
|
||||
```
|
||||
ssh mannitol
|
||||
mkdir pyceo-parent && mv pyceo.tar.gz pyceo-parent/ && cd pyceo-parent
|
||||
rm -iv *.{xz,gz,dsc,build,buildinfo,changes,deb}
|
||||
mkdir pyceo-parent
|
||||
mv pyceo.tar.gz pyceo-parent/
|
||||
cd pyceo-parent
|
||||
tar zxvf pyceo.tar.gz
|
||||
```
|
||||
At this point, you will need a dupload.conf file. Ask someone on syscom for a copy. Place the dupload config at `~/.dupload.conf` (as per manpage).
|
||||
At this point, you will need a dupload.conf file. Ask someone on syscom for a copy.
|
||||
|
||||
Now upload the package to potassium-benzoate:
|
||||
```
|
||||
|
@ -102,21 +80,7 @@ dupload *.changes
|
|||
|
||||
Now SSH into potassium-benzoate and run the following:
|
||||
```
|
||||
# note: this is AUTOMATICALLY done (within 10-20 minutes by a cron job)
|
||||
sudo /srv/debian/bin/rrr-incoming
|
||||
```
|
||||
|
||||
To check if mirror has accepted the new package, visit: http://debian.csclub.uwaterloo.ca/dists/bookworm/
|
||||
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
To update CEO:
|
||||
```
|
||||
# repeat this for all systems, starting from ceod servers
|
||||
sudo apt update
|
||||
# NOTE: be careful of changing configs!!
|
||||
sudo apt install --only-upgrade ceod
|
||||
````
|
||||
|
||||
>>>>>>> Stashed changes
|
||||
There, that wasn't so bad...right? :')
|
||||
|
|
|
@ -1 +1 @@
|
|||
1.0.31
|
||||
1.0.29
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
from zope import component
|
||||
|
@ -8,7 +9,6 @@ from .krb_check import krb_check
|
|||
from .tui.start import main as tui_main
|
||||
from ceo_common.interfaces import IConfig, IHTTPClient
|
||||
from ceo_common.model import Config, HTTPClient
|
||||
from ceo_common.utils import is_in_development
|
||||
|
||||
|
||||
def register_services():
|
||||
|
@ -19,7 +19,8 @@ def register_services():
|
|||
if 'CEO_CONFIG' in os.environ:
|
||||
config_file = os.environ['CEO_CONFIG']
|
||||
else:
|
||||
if is_in_development():
|
||||
# This is a hack to determine if we're in the dev env or not
|
||||
if socket.getfqdn().endswith('.csclub.internal'):
|
||||
config_file = './tests/ceo_dev.ini'
|
||||
else:
|
||||
config_file = '/etc/csc/ceo.ini'
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import socket
|
||||
|
||||
from typing import List, Tuple, Dict
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
from ceo_common.utils import is_in_development
|
||||
from ..utils import space_colon_kv, generic_handle_stream_response
|
||||
from .CLIStreamResponseHandler import CLIStreamResponseHandler
|
||||
|
||||
|
@ -52,6 +53,6 @@ def handle_sync_response(resp: requests.Response):
|
|||
|
||||
def check_if_in_development() -> bool:
|
||||
"""Aborts if we are not currently in the dev environment."""
|
||||
if not is_in_development():
|
||||
if not socket.getfqdn().endswith('.csclub.internal'):
|
||||
click.echo('This command may only be called during development.')
|
||||
raise Abort()
|
||||
|
|
|
@ -7,7 +7,6 @@ position_names = {
|
|||
'cro': "Chief Returning Officer",
|
||||
'webmaster': "Web Master",
|
||||
'offsck': "Office Manager",
|
||||
'events-lead': "Events Lead",
|
||||
'ext-affairs-lead': "External Affairs Lead",
|
||||
'marketing-lead': "Marketing Lead",
|
||||
'design-lead': "Design Lead",
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
import typing
|
||||
from typing import Optional
|
||||
|
||||
from zope.interface import Interface
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
# FIXME: circular import caused by lifting in __init__.py
|
||||
from ..model.ADLDAPRecord import ADLDAPRecord
|
||||
|
||||
|
||||
class IADLDAPService(Interface):
|
||||
"""Represents the AD LDAP database."""
|
||||
|
||||
def get_user(username: str) -> Optional['ADLDAPRecord']:
|
||||
"""
|
||||
Return the LDAP record for the given user, or
|
||||
None if no such record exists.
|
||||
"""
|
|
@ -1,23 +1,20 @@
|
|||
import typing
|
||||
from typing import List, Optional
|
||||
from typing import List, Union
|
||||
|
||||
from zope.interface import Interface
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
# FIXME: circular import caused by lifting in __init__.py
|
||||
from ..model.UWLDAPRecord import UWLDAPRecord
|
||||
|
||||
|
||||
class IUWLDAPService(Interface):
|
||||
"""Represents the UW LDAP database."""
|
||||
|
||||
def get_user(username: str) -> Optional['UWLDAPRecord']:
|
||||
def get_user(username: str):
|
||||
"""
|
||||
Return the LDAP record for the given user, or
|
||||
None if no such record exists.
|
||||
|
||||
:rtype: Union[UWLDAPRecord, None]
|
||||
"""
|
||||
|
||||
def get_programs_for_users(usernames: List[str]) -> List[Optional[str]]:
|
||||
def get_programs_for_users(usernames: List[str]) -> List[Union[str, None]]:
|
||||
"""
|
||||
Return the programs for the given users from UWLDAP.
|
||||
If no record or program is found for a user, their entry in
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
from .IADLDAPService import IADLDAPService
|
||||
from .ICloudResourceManager import ICloudResourceManager
|
||||
from .ICloudStackService import ICloudStackService
|
||||
from .IKerberosService import IKerberosService
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
import ldap3
|
||||
|
||||
from .UWLDAPRecord import UWLDAPRecord
|
||||
|
||||
|
||||
class ADLDAPRecord:
|
||||
"""Represents a record from the AD LDAP."""
|
||||
|
||||
# These are just the ones in which we're interested
|
||||
ldap_attributes = [
|
||||
'sAMAccountName',
|
||||
'mail',
|
||||
'description',
|
||||
'sn',
|
||||
'givenName',
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sam_account_name: str,
|
||||
mail: str,
|
||||
description: str,
|
||||
sn: str,
|
||||
given_name: str,
|
||||
):
|
||||
self.sam_account_name = sam_account_name
|
||||
self.mail = mail
|
||||
self.description = description
|
||||
self.sn = sn
|
||||
self.given_name = given_name
|
||||
|
||||
@staticmethod
|
||||
def deserialize_from_ldap(entry: ldap3.Entry):
|
||||
"""
|
||||
Deserializes a dict returned from LDAP into an
|
||||
ADLDAPRecord.
|
||||
"""
|
||||
return ADLDAPRecord(
|
||||
sam_account_name=entry.sAMAccountName.value,
|
||||
mail=entry.mail.value,
|
||||
description=entry.description.value,
|
||||
sn=entry.sn.value,
|
||||
given_name=entry.givenName.value,
|
||||
)
|
||||
|
||||
def to_uwldap_record(self) -> UWLDAPRecord:
|
||||
"""Converts this AD LDAP record into a UW LDAP record."""
|
||||
return UWLDAPRecord(
|
||||
uid=self.sam_account_name,
|
||||
mail_local_addresses=[self.mail],
|
||||
program=None,
|
||||
# The description attribute has the format "Lastname, Firstname"
|
||||
cn=' '.join(self.description.split(', ')[::-1]),
|
||||
# Alumni's last names are prepended with an asterisk
|
||||
sn=(self.sn[1:] if self.sn[0] == '*' else self.sn),
|
||||
given_name=self.given_name
|
||||
)
|
|
@ -1,6 +1,4 @@
|
|||
from .ADLDAPRecord import ADLDAPRecord
|
||||
from .Config import Config
|
||||
from .HTTPClient import HTTPClient
|
||||
from .RemoteMailmanService import RemoteMailmanService
|
||||
from .Term import Term
|
||||
from .UWLDAPRecord import UWLDAPRecord
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import datetime
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
import socket
|
||||
|
||||
# TODO: disallow underscores. Will break many tests with usernames that include _
|
||||
VALID_USERNAME_RE = re.compile(r"^[a-z][a-z0-9-_]+$")
|
||||
|
@ -71,8 +70,3 @@ def validate_username(username: str) -> UsernameValidationResult:
|
|||
if not VALID_USERNAME_RE.fullmatch(username):
|
||||
return UsernameValidationResult(False, 'Username is invalid')
|
||||
return UsernameValidationResult(True)
|
||||
|
||||
|
||||
def is_in_development() -> bool:
|
||||
"""This is a hack to determine if we're in the dev env or not"""
|
||||
return socket.getfqdn().endswith('.csclub.internal')
|
||||
|
|
|
@ -9,13 +9,13 @@ from .error_handlers import register_error_handlers
|
|||
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, IFileService, \
|
||||
IMailmanService, IMailService, IUWLDAPService, IHTTPClient, IDatabaseService, \
|
||||
ICloudStackService, ICloudResourceManager, IKubernetesService, IVHostManager, \
|
||||
IContainerRegistryService, IClubWebHostingService, IADLDAPService
|
||||
IContainerRegistryService, IClubWebHostingService
|
||||
from ceo_common.model import Config, HTTPClient, RemoteMailmanService
|
||||
from ceod.api.spnego import init_spnego
|
||||
from ceod.model import KerberosService, LDAPService, FileService, \
|
||||
MailmanService, MailService, UWLDAPService, CloudStackService, \
|
||||
CloudResourceManager, KubernetesService, VHostManager, \
|
||||
ContainerRegistryService, ClubWebHostingService, ADLDAPService
|
||||
ContainerRegistryService, ClubWebHostingService
|
||||
from ceod.db import MySQLService, PostgreSQLService
|
||||
|
||||
|
||||
|
@ -36,15 +36,6 @@ def create_app(flask_config={}):
|
|||
from ceod.api import members
|
||||
app.register_blueprint(members.bp, url_prefix='/api/members')
|
||||
|
||||
from ceod.api import groups
|
||||
app.register_blueprint(groups.bp, url_prefix='/api/groups')
|
||||
|
||||
from ceod.api import positions
|
||||
app.register_blueprint(positions.bp, url_prefix='/api/positions')
|
||||
|
||||
from ceod.api import uwldap
|
||||
app.register_blueprint(uwldap.bp, url_prefix='/api/uwldap')
|
||||
|
||||
# Only offer mailman API if this host is running Mailman
|
||||
if hostname == cfg.get('ceod_mailman_host'):
|
||||
from ceod.api import mailman
|
||||
|
@ -62,6 +53,15 @@ def create_app(flask_config={}):
|
|||
from ceod.api import webhosting
|
||||
app.register_blueprint(webhosting.bp, url_prefix='/api/webhosting')
|
||||
|
||||
from ceod.api import groups
|
||||
app.register_blueprint(groups.bp, url_prefix='/api/groups')
|
||||
|
||||
from ceod.api import positions
|
||||
app.register_blueprint(positions.bp, url_prefix='/api/positions')
|
||||
|
||||
from ceod.api import uwldap
|
||||
app.register_blueprint(uwldap.bp, url_prefix='/api/uwldap')
|
||||
|
||||
register_error_handlers(app)
|
||||
|
||||
@app.route('/ping')
|
||||
|
@ -112,13 +112,9 @@ def register_services(app):
|
|||
mail_srv = MailService()
|
||||
component.provideUtility(mail_srv, IMailService)
|
||||
|
||||
# UWLDAPService, ADLDAPService
|
||||
if hostname == cfg.get('ceod_admin_host'):
|
||||
uwldap_srv = UWLDAPService()
|
||||
component.provideUtility(uwldap_srv, IUWLDAPService)
|
||||
|
||||
adldap_srv = ADLDAPService()
|
||||
component.provideUtility(adldap_srv, IADLDAPService)
|
||||
# UWLDAPService
|
||||
uwldap_srv = UWLDAPService()
|
||||
component.provideUtility(uwldap_srv, IUWLDAPService)
|
||||
|
||||
# ClubWebHostingService
|
||||
if hostname == cfg.get('ceod_webhosting_host'):
|
||||
|
|
|
@ -3,8 +3,7 @@ from flask.json import jsonify
|
|||
from zope import component
|
||||
|
||||
from .utils import authz_restrict_to_syscom, is_truthy, \
|
||||
create_streaming_response, development_only, requires_admin_creds, \
|
||||
requires_authentication_no_realm, user_is_in_group
|
||||
create_streaming_response, development_only
|
||||
from ceo_common.interfaces import ILDAPService
|
||||
from ceo_common.utils import fuzzy_result, fuzzy_match
|
||||
from ceod.transactions.groups import (
|
||||
|
@ -53,26 +52,9 @@ def search_group(query, count):
|
|||
return jsonify(result)
|
||||
|
||||
|
||||
def may_add_user_to_group(auth_username: str, group_name: str) -> bool:
|
||||
# (is syscom) OR (group is office AND client is offsck)
|
||||
if user_is_in_group(auth_username, 'syscom'):
|
||||
return True
|
||||
if group_name == 'office':
|
||||
ldap_srv = component.getUtility(ILDAPService)
|
||||
auth_user = ldap_srv.get_user(auth_username)
|
||||
if 'offsck' in auth_user.positions:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@bp.route('/<group_name>/members/<username>', methods=['POST'])
|
||||
@requires_admin_creds
|
||||
@requires_authentication_no_realm
|
||||
def add_member_to_group(auth_username: str, group_name: str, username: str):
|
||||
# Admin creds are required because slapd does not support access control
|
||||
# rules which use the client's attributes
|
||||
if not may_add_user_to_group(auth_username, group_name):
|
||||
return {'error': "not authorized to add user to group"}, 403
|
||||
@authz_restrict_to_syscom
|
||||
def add_member_to_group(group_name, username):
|
||||
subscribe_to_lists = is_truthy(
|
||||
request.args.get('subscribe_to_lists', 'true')
|
||||
)
|
||||
|
@ -85,13 +67,8 @@ def add_member_to_group(auth_username: str, group_name: str, username: str):
|
|||
|
||||
|
||||
@bp.route('/<group_name>/members/<username>', methods=['DELETE'])
|
||||
@requires_admin_creds
|
||||
@requires_authentication_no_realm
|
||||
def remove_member_from_group(auth_username: str, group_name: str, username: str):
|
||||
# Admin creds are required because slapd does not support access control
|
||||
# rules which use the client's attributes
|
||||
if not may_add_user_to_group(auth_username, group_name):
|
||||
return {'error': "not authorized to add user to group"}, 403
|
||||
@authz_restrict_to_syscom
|
||||
def remove_member_from_group(group_name, username):
|
||||
unsubscribe_from_lists = is_truthy(
|
||||
request.args.get('unsubscribe_from_lists', 'true')
|
||||
)
|
||||
|
|
|
@ -158,12 +158,9 @@ def reset_user_password(username: str):
|
|||
|
||||
|
||||
@bp.route('/<username>', methods=['DELETE'])
|
||||
@requires_admin_creds
|
||||
@authz_restrict_to_syscom
|
||||
@development_only
|
||||
def delete_user(username: str):
|
||||
# We use the admin creds for the integration tests for the web app, which
|
||||
# uses the ceod/<host> key
|
||||
txn = DeleteMemberTransaction(username)
|
||||
return create_streaming_response(txn)
|
||||
|
||||
|
|
|
@ -51,12 +51,7 @@ def requires_admin_creds(f: Callable) -> Callable:
|
|||
|
||||
|
||||
def user_is_in_group(username: str, group_name: str) -> bool:
|
||||
"""
|
||||
Returns True if `username` is in `group_name` (or starts with "ceod/"),
|
||||
False otherwise.
|
||||
"""
|
||||
if username.startswith("ceod/"):
|
||||
return True
|
||||
"""Returns True if `username` is in `group_name`, False otherwise."""
|
||||
ldap_srv = component.getUtility(ILDAPService)
|
||||
group = ldap_srv.get_group(group_name)
|
||||
return username in group.members
|
||||
|
|
|
@ -3,25 +3,15 @@ from flask.json import jsonify
|
|||
from zope import component
|
||||
|
||||
from .utils import authz_restrict_to_syscom, is_truthy
|
||||
from ceo_common.interfaces import IUWLDAPService, IADLDAPService, ILDAPService
|
||||
from ceo_common.logger_factory import logger_factory
|
||||
from ceo_common.interfaces import IUWLDAPService, ILDAPService
|
||||
|
||||
bp = Blueprint('uwldap', __name__)
|
||||
logger = logger_factory(__name__)
|
||||
|
||||
|
||||
@bp.route('/<username>')
|
||||
def get_user(username: str):
|
||||
uwldap_srv = component.getUtility(IUWLDAPService)
|
||||
record = uwldap_srv.get_user(username)
|
||||
if record is not None and not record.sn:
|
||||
# Alumni are missing a lot of information in UWLDAP.
|
||||
# Try AD LDAP instead
|
||||
logger.debug('Querying %s from AD LDAP', username)
|
||||
adldap_srv = component.getUtility(IADLDAPService)
|
||||
ad_record = adldap_srv.get_user(username)
|
||||
if ad_record is not None:
|
||||
record = ad_record.to_uwldap_record()
|
||||
if record is None:
|
||||
return {
|
||||
'error': 'user not found',
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
from typing import Optional
|
||||
|
||||
import dns.resolver
|
||||
import ldap3
|
||||
from zope import component
|
||||
from zope.interface import implementer
|
||||
|
||||
from ceo_common.interfaces import IADLDAPService, IConfig
|
||||
from ceo_common.logger_factory import logger_factory
|
||||
from ceo_common.model import ADLDAPRecord
|
||||
from ceo_common.utils import is_in_development
|
||||
|
||||
logger = logger_factory(__name__)
|
||||
|
||||
|
||||
@implementer(IADLDAPService)
|
||||
class ADLDAPService:
|
||||
def __init__(self):
|
||||
cfg = component.getUtility(IConfig)
|
||||
if is_in_development():
|
||||
self.adldap_dns_srv_name = None
|
||||
self.adldap_server_url = cfg.get('adldap_server_url')
|
||||
else:
|
||||
self.adldap_dns_srv_name = cfg.get('adldap_dns_srv_name')
|
||||
# Perform the actual DNS query later so that we don't delay startup
|
||||
self.adldap_server_url = None
|
||||
self.adldap_base = cfg.get('adldap_base')
|
||||
|
||||
def _get_server_url(self) -> str:
|
||||
assert self.adldap_dns_srv_name is not None
|
||||
answers = dns.resolver.resolve(self.adldap_dns_srv_name, 'SRV')
|
||||
target = answers[0].target.to_text()
|
||||
# Strip the trailing '.'
|
||||
target = target[:-1]
|
||||
logger.debug('Using AD LDAP server %s', target)
|
||||
# ldaps doesn't seem to work
|
||||
return 'ldap://' + target
|
||||
|
||||
def _get_conn(self) -> ldap3.Connection:
|
||||
if self.adldap_server_url is None:
|
||||
self.adldap_server_url = self._get_server_url()
|
||||
# When get_info=ldap3.SCHEMA (the default), ldap3 tries to search
|
||||
# for schema information in 'CN=Schema,CN=Configuration,DC=ds,DC=uwaterloo,DC=ca',
|
||||
# which doesn't exist so a LDAPNoSuchObjectResult exception is raised.
|
||||
# To avoid this, we tell ldap3 not to look up any server info.
|
||||
server = ldap3.Server(self.adldap_server_url, get_info=ldap3.NONE)
|
||||
return ldap3.Connection(
|
||||
server, auto_bind=True, read_only=True, raise_exceptions=True)
|
||||
|
||||
def get_user(self, username: str) -> Optional[ADLDAPRecord]:
|
||||
conn = self._get_conn()
|
||||
conn.search(
|
||||
self.adldap_base, f'(sAMAccountName={username})',
|
||||
attributes=ADLDAPRecord.ldap_attributes, size_limit=1)
|
||||
if not conn.entries:
|
||||
return None
|
||||
return ADLDAPRecord.deserialize_from_ldap(conn.entries[0])
|
|
@ -1,11 +1,11 @@
|
|||
from typing import List, Optional
|
||||
from typing import Union, List
|
||||
|
||||
import ldap3
|
||||
from zope import component
|
||||
from zope.interface import implementer
|
||||
|
||||
from .UWLDAPRecord import UWLDAPRecord
|
||||
from ceo_common.interfaces import IUWLDAPService, IConfig
|
||||
from ceo_common.model import UWLDAPRecord
|
||||
|
||||
|
||||
@implementer(IUWLDAPService)
|
||||
|
@ -20,7 +20,7 @@ class UWLDAPService:
|
|||
self.uwldap_server_url, auto_bind=True, read_only=True,
|
||||
raise_exceptions=True)
|
||||
|
||||
def get_user(self, username: str) -> Optional[UWLDAPRecord]:
|
||||
def get_user(self, username: str) -> Union[UWLDAPRecord, None]:
|
||||
conn = self._get_conn()
|
||||
conn.search(
|
||||
self.uwldap_base, f'(uid={username})',
|
||||
|
@ -29,7 +29,7 @@ class UWLDAPService:
|
|||
return None
|
||||
return UWLDAPRecord.deserialize_from_ldap(conn.entries[0])
|
||||
|
||||
def get_programs_for_users(self, usernames: List[str]) -> List[Optional[str]]:
|
||||
def get_programs_for_users(self, usernames: List[str]) -> List[Union[str, None]]:
|
||||
filter_str = '(|' + ''.join([f'(uid={uid})' for uid in usernames]) + ')'
|
||||
programs = [None] * len(usernames)
|
||||
user_indices = {uid: i for i, uid in enumerate(usernames)}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
from .ADLDAPService import ADLDAPService
|
||||
from .CloudResourceManager import CloudResourceManager
|
||||
from .CloudStackService import CloudStackService
|
||||
from .KerberosService import KerberosService
|
||||
|
@ -6,6 +5,7 @@ from .LDAPService import LDAPService, UserNotFoundError, GroupNotFoundError
|
|||
from .User import User
|
||||
from .Group import Group
|
||||
from .UWLDAPService import UWLDAPService
|
||||
from .UWLDAPRecord import UWLDAPRecord
|
||||
from .FileService import FileService
|
||||
from .SudoRole import SudoRole
|
||||
from .MailService import MailService
|
||||
|
|
|
@ -1,18 +1,3 @@
|
|||
ceo (1.0.31-bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Allow office manager to manage office group
|
||||
* Fix regression issue for new positions
|
||||
|
||||
-- Nathan <n4chung@csclub.uwaterloo.ca> Wed, 21 Feb 2024 06:40:29 +0000
|
||||
|
||||
ceo (1.0.30-bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Support for new positions
|
||||
* Improved username validation
|
||||
* UWLDAP API integration for alumni information
|
||||
|
||||
-- Nathan <n4chung@csclub.uwaterloo.ca> Sun, 04 Feb 2024 19:38:18 +0000
|
||||
|
||||
ceo (1.0.29-bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Upgrade dependencies
|
||||
|
|
|
@ -7,7 +7,6 @@ Vcs-Git: https://git.csclub.uwaterloo.ca/public/pyceo.git
|
|||
Vcs-Browser: https://git.csclub.uwaterloo.ca/public/pyceo
|
||||
Uploaders: Max Erenberg <merenber@csclub.uwaterloo.ca>,
|
||||
Raymond Li <raymo@csclub.uwaterloo.ca>,
|
||||
Nathan <n4chung@csclub.uwaterloo.ca>,
|
||||
Edwin <e42zhang@csclub.uwaterloo.ca>
|
||||
Build-Depends: debhelper (>= 13),
|
||||
python3-dev (>= 3.9),
|
||||
|
|
|
@ -1,17 +1,15 @@
|
|||
version: "3.6"
|
||||
|
||||
x-common: &common
|
||||
image: ceo-generic:bullseye
|
||||
volumes:
|
||||
- ceo-venv:/app/venv:ro
|
||||
- ./.drone:/app/.drone:ro
|
||||
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh:ro
|
||||
- ceo-venv:/app/venv:ro
|
||||
- ./ceo:/app/ceo:ro
|
||||
- ./ceo_common:/app/ceo_common:ro
|
||||
- ./ceod:/app/ceod:ro
|
||||
- ./tests:/app/tests:ro
|
||||
# for flake8
|
||||
- ./setup.cfg:/app/setup.cfg:ro
|
||||
- ./web:/app/web:z
|
||||
security_opt:
|
||||
- label:disable
|
||||
working_dir: /app
|
||||
|
@ -20,7 +18,6 @@ x-common: &common
|
|||
|
||||
x-ceod-common: &ceod-common
|
||||
<<: *common
|
||||
image: ceo-generic:bullseye
|
||||
environment:
|
||||
FLASK_APP: ceod.api
|
||||
FLASK_DEBUG: "true"
|
||||
|
|
|
@ -30,10 +30,6 @@ sudo_base = ou=SUDOers,dc=csclub,dc=uwaterloo,dc=ca
|
|||
server_url = ldaps://uwldap.uwaterloo.ca
|
||||
base = dc=uwaterloo,dc=ca
|
||||
|
||||
[adldap]
|
||||
dns_srv_name = _ldap._tcp.teaching.ds.uwaterloo.ca
|
||||
base = dc=teaching,dc=ds,dc=uwaterloo,dc=ca
|
||||
|
||||
[members]
|
||||
min_id = 20001
|
||||
max_id = 29999
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
click==8.1.6
|
||||
cryptography==41.0.2
|
||||
dnspython==2.5.0
|
||||
Flask==2.3.2
|
||||
gssapi==1.8.2
|
||||
gunicorn==21.2.0
|
||||
|
|
|
@ -35,7 +35,6 @@ if ! $DOCKER volume exists ceo-venv; then
|
|||
$DOCKER volume create ceo-venv
|
||||
$DOCKER run \
|
||||
--rm \
|
||||
--security-opt label=disable \
|
||||
-w /app/venv \
|
||||
-v ceo-venv:/app/venv:z \
|
||||
-v ./requirements.txt:/tmp/requirements.txt:ro \
|
||||
|
|
|
@ -136,13 +136,12 @@ def test_members_renew(cli_setup, ldap_user, g_admin_ctx):
|
|||
assert result.output == expected
|
||||
|
||||
|
||||
def test_members_pwreset(cli_setup, ldap_and_krb_user):
|
||||
uid = ldap_and_krb_user.uid
|
||||
def test_members_pwreset(cli_setup, ldap_user, krb_user):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ['members', 'pwreset', uid], input='y\n')
|
||||
cli, ['members', 'pwreset', ldap_user.uid], input='y\n')
|
||||
expected_pat = re.compile((
|
||||
f"^Are you sure you want to reset {uid}'s password\\? \\[y/N\\]: y\n"
|
||||
f"^Are you sure you want to reset {ldap_user.uid}'s password\\? \\[y/N\\]: y\n"
|
||||
"New password: \\S+\n$"
|
||||
), re.MULTILINE)
|
||||
assert result.exit_code == 0
|
||||
|
|
|
@ -5,8 +5,8 @@ from mysql.connector import connect
|
|||
from mysql.connector.errors import ProgrammingError
|
||||
|
||||
|
||||
def test_api_create_mysql_db(cfg, client, g_admin_ctx, ldap_and_krb_user):
|
||||
uid = ldap_and_krb_user.uid
|
||||
def test_api_create_mysql_db(cfg, client, g_admin_ctx, ldap_user, krb_user):
|
||||
uid = ldap_user.uid
|
||||
|
||||
with g_admin_ctx():
|
||||
user = User(uid='someone_else', cn='Some Name', given_name='Some',
|
||||
|
@ -72,8 +72,8 @@ def test_api_create_mysql_db(cfg, client, g_admin_ctx, ldap_and_krb_user):
|
|||
user.remove_from_ldap()
|
||||
|
||||
|
||||
def test_api_passwd_reset_mysql(cfg, client, g_admin_ctx, ldap_and_krb_user):
|
||||
uid = ldap_and_krb_user.uid
|
||||
def test_api_passwd_reset_mysql(cfg, client, g_admin_ctx, ldap_user, krb_user):
|
||||
uid = ldap_user.uid
|
||||
|
||||
with g_admin_ctx():
|
||||
user = User(uid='someone_else', cn='Some Name', given_name='Some',
|
||||
|
|
|
@ -4,8 +4,8 @@ from ceod.model import User
|
|||
from psycopg2 import connect, OperationalError, ProgrammingError
|
||||
|
||||
|
||||
def test_api_create_psql_db(cfg, client, g_admin_ctx, ldap_and_krb_user):
|
||||
uid = ldap_and_krb_user.uid
|
||||
def test_api_create_psql_db(cfg, client, g_admin_ctx, ldap_user, krb_user):
|
||||
uid = ldap_user.uid
|
||||
|
||||
with g_admin_ctx():
|
||||
user = User(uid='someone_else', cn='Some Name', given_name='Some',
|
||||
|
@ -74,8 +74,8 @@ def test_api_create_psql_db(cfg, client, g_admin_ctx, ldap_and_krb_user):
|
|||
user.remove_from_ldap()
|
||||
|
||||
|
||||
def test_api_passwd_reset_psql(cfg, client, g_admin_ctx, ldap_and_krb_user):
|
||||
uid = ldap_and_krb_user.uid
|
||||
def test_api_passwd_reset_psql(cfg, client, g_admin_ctx, ldap_user, krb_user):
|
||||
uid = ldap_user.uid
|
||||
|
||||
with g_admin_ctx():
|
||||
user = User(uid='someone_else', cn='Some Name', given_name='Some',
|
||||
|
|
|
@ -2,7 +2,6 @@ import ldap3
|
|||
import pytest
|
||||
|
||||
from ceod.model import Group
|
||||
from tests.conftest_ceod_api import expect_successful_txn
|
||||
|
||||
|
||||
def test_api_group_not_found(client):
|
||||
|
@ -10,28 +9,20 @@ def test_api_group_not_found(client):
|
|||
assert status == 404
|
||||
|
||||
|
||||
def create_group(client, cn: str, description: str):
|
||||
@pytest.fixture(scope='module')
|
||||
def create_group_resp(client):
|
||||
status, data = client.post('/api/groups', json={
|
||||
'cn': cn,
|
||||
'description': description,
|
||||
'cn': 'test_group1',
|
||||
'description': 'Test Group One',
|
||||
})
|
||||
assert status == 200
|
||||
assert data[-1]['status'] == 'completed'
|
||||
return status, data
|
||||
|
||||
|
||||
def delete_group(client, cn: str):
|
||||
status, data = client.delete(f'/api/groups/{cn}')
|
||||
yield status, data
|
||||
status, data = client.delete('/api/groups/test_group1')
|
||||
assert status == 200
|
||||
assert data[-1]['status'] == 'completed'
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def create_group_resp(client):
|
||||
yield create_group(client, 'test_group1', 'Test Group One')
|
||||
delete_group(client, 'test_group1')
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def create_group_result(create_group_resp):
|
||||
# convenience method
|
||||
|
@ -135,7 +126,12 @@ def create_random_names():
|
|||
def create_searchable_groups(client, create_random_names):
|
||||
random_names = create_random_names
|
||||
for name in random_names:
|
||||
create_group(client, name, 'Groups with distinct names for testing searching')
|
||||
status, data = client.post('/api/groups', json={
|
||||
'cn': name,
|
||||
'description': 'Groups with distinct names for testing searching',
|
||||
})
|
||||
assert status == 200
|
||||
assert data[-1]['status'] == 'completed'
|
||||
yield random_names
|
||||
|
||||
|
||||
|
@ -214,27 +210,3 @@ def test_api_group_auxiliary(cfg, client, ldap_user, g_admin_ctx):
|
|||
for group in groups:
|
||||
if group.cn != 'syscom':
|
||||
group.remove_from_ldap()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('desc,expect_success', [
|
||||
('in_syscom', True),
|
||||
('is_offsck', True),
|
||||
(None, False),
|
||||
])
|
||||
def test_api_add_or_remove_member_from_office(desc, expect_success, cfg, client, ldap_and_krb_user, new_user, g_admin_ctx):
|
||||
uid = ldap_and_krb_user.uid
|
||||
if desc == 'in_syscom':
|
||||
uid = 'ctdalek'
|
||||
elif desc == 'is_offsck':
|
||||
with g_admin_ctx():
|
||||
ldap_and_krb_user.set_positions(['offsck'])
|
||||
|
||||
def handle_resp(status_and_data):
|
||||
if expect_success:
|
||||
expect_successful_txn(status_and_data)
|
||||
else:
|
||||
status = status_and_data[0]
|
||||
assert status == 403
|
||||
|
||||
handle_resp(client.post(f'/api/groups/office/members/{new_user.uid}', principal=uid))
|
||||
handle_resp(client.delete(f'/api/groups/office/members/{new_user.uid}', principal=uid))
|
||||
|
|
|
@ -50,19 +50,3 @@ def test_updateprograms(
|
|||
# make sure that the user was changed
|
||||
status, data = client.get(f'/api/members/{uwldap_user.uid}')
|
||||
assert data['program'] == 'New Program'
|
||||
|
||||
|
||||
def test_get_alumni(client):
|
||||
uid = 'alumni1'
|
||||
status, data = client.get(f'/api/uwldap/{uid}')
|
||||
assert status == 200
|
||||
expected = {
|
||||
"cn": 'Alumni One',
|
||||
"given_name": 'Alumni',
|
||||
"sn": 'One',
|
||||
# This should be the email address from AD LDAP
|
||||
"mail_local_addresses": [f'{uid}@alumni.uwaterloo.internal'],
|
||||
# Program attribute should be missing
|
||||
"uid": uid,
|
||||
}
|
||||
assert data == expected
|
||||
|
|
|
@ -27,10 +27,6 @@ sudo_base = ou=SUDOers,dc=csclub,dc=internal
|
|||
server_url = ldap://auth1.csclub.internal
|
||||
base = ou=UWLDAP,dc=csclub,dc=internal
|
||||
|
||||
[adldap]
|
||||
server_url = ldap://auth1.csclub.internal
|
||||
base = ou=ADLDAP,dc=csclub,dc=internal
|
||||
|
||||
[members]
|
||||
min_id = 20001
|
||||
max_id = 29999
|
||||
|
|
|
@ -26,10 +26,6 @@ sudo_base = ou=TestSUDOers,dc=csclub,dc=internal
|
|||
server_url = ldap://auth1.csclub.internal
|
||||
base = ou=TestUWLDAP,dc=csclub,dc=internal
|
||||
|
||||
[adldap]
|
||||
server_url = ldap://auth1.csclub.internal
|
||||
base = ou=TestADLDAP,dc=csclub,dc=internal
|
||||
|
||||
[members]
|
||||
# 20000 is ctdalek, 20001 is office1
|
||||
min_id = 20002
|
||||
|
|
|
@ -30,16 +30,15 @@ from .utils import ( # noqa: F401
|
|||
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, \
|
||||
IFileService, IMailmanService, IHTTPClient, IUWLDAPService, IMailService, \
|
||||
IDatabaseService, ICloudStackService, IKubernetesService, IVHostManager, \
|
||||
ICloudResourceManager, IContainerRegistryService, IClubWebHostingService, \
|
||||
IADLDAPService
|
||||
from ceo_common.model import Config, HTTPClient, Term, UWLDAPRecord
|
||||
ICloudResourceManager, IContainerRegistryService, IClubWebHostingService
|
||||
from ceo_common.model import Config, HTTPClient, Term
|
||||
import ceo_common.utils
|
||||
from ceod.api import create_app
|
||||
from ceod.db import MySQLService, PostgreSQLService
|
||||
from ceod.model import KerberosService, LDAPService, FileService, User, \
|
||||
MailmanService, Group, UWLDAPService, MailService, \
|
||||
MailmanService, Group, UWLDAPService, UWLDAPRecord, MailService, \
|
||||
CloudStackService, KubernetesService, VHostManager, CloudResourceManager, \
|
||||
ContainerRegistryService, ClubWebHostingService, ADLDAPService
|
||||
ContainerRegistryService, ClubWebHostingService
|
||||
from .MockSMTPServer import MockSMTPServer
|
||||
from .MockMailmanServer import MockMailmanServer
|
||||
from .MockCloudStackServer import MockCloudStackServer
|
||||
|
@ -310,17 +309,6 @@ def uwldap_srv(cfg, ldap_conn):
|
|||
'givenName': 'Calum',
|
||||
},
|
||||
)
|
||||
conn.add(
|
||||
f'uid=alumni1,{base_dn}',
|
||||
['inetLocalMailRecipient', 'inetOrgPerson', 'organizationalPerson', 'person'],
|
||||
{
|
||||
'mailLocalAddress': 'alumni1@uwaterloo.internal',
|
||||
'ou': 'Alumni',
|
||||
'cn': 'Alumni',
|
||||
'sn': 'One',
|
||||
'givenName': 'Alumni',
|
||||
},
|
||||
)
|
||||
_uwldap_srv = UWLDAPService()
|
||||
component.getGlobalSiteManager().registerUtility(_uwldap_srv, IUWLDAPService)
|
||||
yield _uwldap_srv
|
||||
|
@ -328,31 +316,6 @@ def uwldap_srv(cfg, ldap_conn):
|
|||
delete_subtree(conn, base_dn)
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def adldap_srv(cfg, ldap_conn):
|
||||
conn = ldap_conn
|
||||
base_dn = cfg.get('adldap_base')
|
||||
delete_subtree(conn, base_dn)
|
||||
conn.add(base_dn, 'organizationalUnit')
|
||||
conn.add(
|
||||
f'cn=alumni1,{base_dn}',
|
||||
['mockADUser', 'inetOrgPerson', 'organizationalPerson', 'person'],
|
||||
{
|
||||
'description': 'One, Alumni',
|
||||
'givenName': 'Alumni',
|
||||
'sn': '*One',
|
||||
'cn': 'alumni1',
|
||||
'sAMAccountName': 'alumni1',
|
||||
'displayName': 'alumni1',
|
||||
'mail': 'alumni1@alumni.uwaterloo.internal',
|
||||
},
|
||||
)
|
||||
_adldap_srv = ADLDAPService()
|
||||
component.getGlobalSiteManager().registerUtility(_adldap_srv, IADLDAPService)
|
||||
yield _adldap_srv
|
||||
delete_subtree(conn, base_dn)
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def webhosting_srv():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
|
@ -413,25 +376,14 @@ def postgresql_srv(cfg):
|
|||
return psql_srv
|
||||
|
||||
|
||||
def delete_dir_contents(dir: str):
|
||||
if os.path.isdir(dir):
|
||||
for item in os.listdir(dir):
|
||||
path = os.path.join(dir, item)
|
||||
if os.path.isdir(path):
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def vhost_dir_setup(cfg):
|
||||
state_dir = '/run/ceod'
|
||||
# Don't delete the directory itself because the non-test ceod process
|
||||
# is using it
|
||||
delete_dir_contents(state_dir)
|
||||
os.makedirs(state_dir, exist_ok=True)
|
||||
if os.path.isdir(state_dir):
|
||||
shutil.rmtree(state_dir)
|
||||
os.makedirs(state_dir)
|
||||
yield
|
||||
delete_dir_contents(state_dir)
|
||||
shutil.rmtree(state_dir)
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
|
@ -470,7 +422,6 @@ def app(
|
|||
file_srv,
|
||||
mailman_srv,
|
||||
uwldap_srv,
|
||||
adldap_srv,
|
||||
mail_srv,
|
||||
mysql_srv,
|
||||
postgresql_srv,
|
||||
|
@ -538,12 +489,6 @@ def krb_user(simple_user):
|
|||
simple_user.remove_from_kerberos()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ldap_and_krb_user(ldap_user, krb_user):
|
||||
# Note that ldap_user and krb_user are both the same person (simple_user)
|
||||
yield ldap_user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def new_user_gen(
|
||||
client, g_admin_ctx, ldap_srv_session, mocks_for_create_user, # noqa: F811
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import json
|
||||
import socket
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import flask
|
||||
from flask.testing import FlaskClient
|
||||
|
@ -77,9 +76,3 @@ class CeodTestClient:
|
|||
|
||||
def put(self, path, principal=None, need_auth=True, delegate=True, **kwargs):
|
||||
return self.request('PUT', path, principal, need_auth, delegate, **kwargs)
|
||||
|
||||
|
||||
def expect_successful_txn(status_and_data: Tuple[int, List[Dict[str, Any]]]) -> None:
|
||||
status, data = status_and_data
|
||||
assert status == 200
|
||||
assert data[-1]['status'] == 'completed'
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
/ceod-web
|
||||
/app.sock
|
||||
/test
|
|
@ -1,80 +0,0 @@
|
|||
# ceod-web
|
||||
This directory contains an experimental self-service web portal for CSC
|
||||
members to use. It is an alternative to the ceo TUI. Currently it is only
|
||||
meant to be used by general members, but in the future it may be extended
|
||||
to be used by syscom/office members as well.
|
||||
|
||||
Implemented APIs:
|
||||
- [x] Password reset
|
||||
- [ ] Change login shell
|
||||
- [ ] Change forwarding addresses
|
||||
- [ ] Show membership terms
|
||||
|
||||
## Development
|
||||
Make sure the Docker containers for ceo are running. Build the "web"
|
||||
executable on the host, then run it in the phosphoric-acid container:
|
||||
```sh
|
||||
# Don't use cgo because the glibc version in the container will likely be
|
||||
# older than the one on the host
|
||||
CGO_ENABLED=0 go build -o ceod-web
|
||||
docker-compose exec phosphoric-acid bash
|
||||
cd web
|
||||
./ceod-web -c dev.json
|
||||
```
|
||||
|
||||
The application will listen on a Unix socket. In production, it expects to
|
||||
receive ADFS information from Apache, which is acting as a reverse proxy.
|
||||
In development, we will use our own proxy instead:
|
||||
```sh
|
||||
# On the host
|
||||
go run scripts/proxy.go -s app.sock -u ctdalek -f Calum
|
||||
```
|
||||
Now you should be able to visit http://localhost:9988 in your browser.
|
||||
|
||||
NOTE: If you are not accessing the website via "localhost" (e.g. you are using
|
||||
some custom proxy setup), then you need to modify the value of "app_url" in
|
||||
dev.json. The app_url value must be equal to the base URL which you enter in
|
||||
your browser's address bar, otherwise the cookie domain will not match.
|
||||
|
||||
You can change the `-u` (username) and `-f` (first name) arguments to the proxy.go
|
||||
program to simulate a different user. See the .drone/data.ldif file in the parent
|
||||
directory to see all mock users.
|
||||
|
||||
### Templates and static assets
|
||||
In development, the templated views and static assets will be loaded from the
|
||||
internal/views and internal/static folders, respectively. Unfortunately I think
|
||||
that the Echo framework caches those files internally so you will need to
|
||||
restart the app process if you modify them.
|
||||
|
||||
### Emails
|
||||
By default, no emails will be sent; they will only be printed to stdout. To
|
||||
send real emails, add these fields to the dev.json (replace `your_username`):
|
||||
```json
|
||||
{
|
||||
...
|
||||
"mta": "mail.csclub.uwaterloo.ca:25",
|
||||
"forced_email_recipient": "your_username@csclub.uwaterloo.ca"
|
||||
}
|
||||
```
|
||||
This will send all of the emails to your email address (the `To` header will
|
||||
still be preserved, however).
|
||||
|
||||
## Tests
|
||||
```sh
|
||||
# On the host
|
||||
CGO_ENABLED=0 go test -c -o test ./tests
|
||||
# In the container
|
||||
./test -test.v
|
||||
```
|
||||
|
||||
## Deployment
|
||||
Apache configuration on caffeine:
|
||||
```
|
||||
Redirect permanent /ceo /ceo/
|
||||
<Location /ceo/ >
|
||||
Include snippets/adfs-require-auth.conf
|
||||
Include snippets/adfs-set-headers.conf
|
||||
ProxyPass "unix:/run/ceod-web/app.sock|http://ceod-web/"
|
||||
ProxyPassReverse "unix:/run/ceod-web/app.sock|http://ceod-web/"
|
||||
</Location>
|
||||
```
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"app_url": "http://localhost:9988",
|
||||
"socket_path": "app.sock",
|
||||
"csc_domain": "csclub.internal",
|
||||
"uw_domain": "uwaterloo.internal",
|
||||
"dev": true
|
||||
}
|
34
web/go.mod
34
web/go.mod
|
@ -1,34 +0,0 @@
|
|||
module git.csclub.uwaterloo.ca/public/pyceo/web
|
||||
|
||||
go 1.21.4
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.8.1
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4
|
||||
github.com/labstack/echo/v4 v4.11.4
|
||||
github.com/labstack/gommon v0.4.2
|
||||
github.com/stretchr/testify v1.8.4
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/gofork v1.7.6 // indirect
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
golang.org/x/crypto v0.17.0 // indirect
|
||||
golang.org/x/net v0.19.0 // indirect
|
||||
golang.org/x/sys v0.15.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
103
web/go.sum
103
web/go.sum
|
@ -1,103 +0,0 @@
|
|||
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
|
||||
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
@ -1,180 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/labstack/gommon/log"
|
||||
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/app"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/service"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
|
||||
)
|
||||
|
||||
type TemplateManager struct {
|
||||
templates map[string]*template.Template
|
||||
}
|
||||
|
||||
func newTemplateManager(isDev bool) *TemplateManager {
|
||||
var viewsFS fs.FS
|
||||
if isDev {
|
||||
viewsFS = os.DirFS("internal/views")
|
||||
} else {
|
||||
var err error
|
||||
viewsFS, err = fs.Sub(internal.EmbeddedViews, "views")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
base := template.Must(template.ParseFS(viewsFS, "base.html"))
|
||||
// Adapted from https://stackoverflow.com/a/24120195
|
||||
parse := func(filenames ...string) *template.Template {
|
||||
clone := template.Must(base.Clone())
|
||||
return template.Must(clone.ParseFS(viewsFS, filenames...))
|
||||
}
|
||||
return &TemplateManager{templates: map[string]*template.Template{
|
||||
"root": parse("root.html"),
|
||||
"pwreset": parse("pwreset.html"),
|
||||
"pwreset_confirmation": parse("pwreset_confirmation.html"),
|
||||
"app_error": parse("app_error.html"),
|
||||
"4xx": parse("4xx.html"),
|
||||
"500": parse("500.html"),
|
||||
}}
|
||||
}
|
||||
|
||||
func (t *TemplateManager) Render(w io.Writer, name string, data any, c echo.Context) error {
|
||||
tmpl, ok := t.templates[name]
|
||||
if !ok {
|
||||
c.Logger().Errorf("No such template '%s'", name)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Internal server error")
|
||||
}
|
||||
return tmpl.Execute(w, data)
|
||||
}
|
||||
|
||||
func addStaticAssets(isDev bool, e *echo.Echo) {
|
||||
var staticFS fs.FS
|
||||
if isDev {
|
||||
staticFS = os.DirFS("internal")
|
||||
} else {
|
||||
staticFS = internal.EmbeddedAssets
|
||||
}
|
||||
e.Group("static").Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||
Root: "static",
|
||||
Filesystem: http.FS(staticFS),
|
||||
}))
|
||||
}
|
||||
|
||||
func newListener(cfg *config.Config) net.Listener {
|
||||
sockPath := cfg.SocketPath
|
||||
if _, err := os.Stat(sockPath); err == nil {
|
||||
err = os.Remove(sockPath)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("Could not remove %s: %w", sockPath, err))
|
||||
}
|
||||
}
|
||||
l, err := net.Listen("unix", sockPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if !cfg.IsDev && !cfg.NoSocketAuth {
|
||||
// Only www-data should be allowed to write to the socket, otherwise
|
||||
// users could forge the X-CSC-ADFS-* headers and reset other people's
|
||||
// passwords
|
||||
err = os.Chmod(sockPath, 0)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
cmd := exec.Command("/bin/setfacl", "-m", "u:www-data:rw", sockPath)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func NewAPI(cfg *config.Config, ceodSrv model.CeodService, mailSrv service.MailService) *echo.Echo {
|
||||
e := echo.New()
|
||||
app := app.NewApp(cfg, mailSrv)
|
||||
e.Logger.SetLevel(log.DEBUG)
|
||||
e.Use(middleware.Logger())
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(middleware.RequestID())
|
||||
e.Use(helmet(cfg))
|
||||
addStaticAssets(cfg.IsDev, e)
|
||||
e.Use(appContextMiddleware(app))
|
||||
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
|
||||
TokenLookup: "cookie:" + cfg.CookieName,
|
||||
CookieName: "ceod-web-csrf",
|
||||
CookiePath: cfg.GetAppPath(),
|
||||
CookieDomain: cfg.GetAppHostname(),
|
||||
CookieSecure: !cfg.IsDev,
|
||||
CookieHTTPOnly: true,
|
||||
CookieSameSite: http.SameSiteStrictMode,
|
||||
}))
|
||||
e.HTTPErrorHandler = httpErrorHandler
|
||||
e.Renderer = newTemplateManager(cfg.IsDev)
|
||||
addRoutes(e)
|
||||
return e
|
||||
}
|
||||
|
||||
func Start(cfg *config.Config) {
|
||||
e := NewAPI(cfg, service.NewCeodService(cfg), service.NewMailService(cfg))
|
||||
listener := newListener(cfg)
|
||||
e.Logger.Info("Listening on " + cfg.SocketPath)
|
||||
e.Logger.Fatal(http.Serve(listener, e))
|
||||
}
|
||||
|
||||
func addRoutes(e *echo.Echo) {
|
||||
e.GET("/", getRoot)
|
||||
e.GET("/pwreset", getPwreset)
|
||||
e.POST("/pwreset", postPwreset)
|
||||
}
|
||||
|
||||
func getRoot(c echo.Context) error {
|
||||
ac := c.(*appContext)
|
||||
_, err := ac.app.GetReqUser(ac)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Render(http.StatusOK, "root", map[string]any{"firstName": ac.req.GivenName})
|
||||
}
|
||||
|
||||
func getPwreset(c echo.Context) error {
|
||||
ac := c.(*appContext)
|
||||
emailAddress, err := ac.app.PwresetCheck(ac)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Double-submit cookie
|
||||
renderData := map[string]any{
|
||||
"emailAddress": emailAddress,
|
||||
"csrf": c.Get("csrf"),
|
||||
}
|
||||
return c.Render(http.StatusOK, "pwreset", renderData)
|
||||
}
|
||||
|
||||
func postPwreset(c echo.Context) error {
|
||||
if c.Get("csrf") != c.FormValue("_csrf") {
|
||||
return newApiError(ERROR_INVALID_CSRF_TOKEN)
|
||||
}
|
||||
ac := c.(*appContext)
|
||||
emailAddress, err := ac.app.Pwreset(ac)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
renderData := map[string]any{"emailAddress": emailAddress}
|
||||
return c.Render(http.StatusOK, "pwreset_confirmation", renderData)
|
||||
}
|
|
@ -1,99 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/app"
|
||||
)
|
||||
|
||||
const (
|
||||
ERROR_INVALID_CSRF_TOKEN = iota + 1
|
||||
)
|
||||
const (
|
||||
membershipURL = "https://csclub.uwaterloo.ca/get-involved/"
|
||||
syscomURL = "mailto:syscom@csclub.uwaterloo.ca"
|
||||
)
|
||||
|
||||
type ApiError struct {
|
||||
Code int
|
||||
}
|
||||
|
||||
func newApiError(code int) ApiError {
|
||||
return ApiError{Code: code}
|
||||
}
|
||||
func (a ApiError) Error() string {
|
||||
return "API error"
|
||||
}
|
||||
|
||||
func renderHTTPErrorPage(c echo.Context, code int, name string) {
|
||||
data := map[string]any{"statusText": http.StatusText(code)}
|
||||
if err := c.Render(code, name, data); err != nil {
|
||||
c.Logger().Error(err)
|
||||
// Fall back to plain text response
|
||||
_ = c.String(http.StatusInternalServerError, "Internal server error")
|
||||
}
|
||||
}
|
||||
|
||||
func renderAppErrorPage(c echo.Context, renderInfo *errorRenderInfo) {
|
||||
if err := c.Render(http.StatusOK, "app_error", renderInfo); err != nil {
|
||||
c.Logger().Error(err)
|
||||
// Fall back to plain text response
|
||||
_ = c.String(http.StatusInternalServerError, "Internal server error")
|
||||
}
|
||||
}
|
||||
|
||||
type errorRenderInfo struct {
|
||||
Title string
|
||||
HtmlFragment template.HTML
|
||||
}
|
||||
|
||||
func newErrorRenderInfo(title string, htmlFragment string) *errorRenderInfo {
|
||||
return &errorRenderInfo{Title: title, HtmlFragment: template.HTML(htmlFragment)}
|
||||
}
|
||||
|
||||
var appErrorInfos = map[int]*errorRenderInfo{
|
||||
app.ERROR_NO_SUCH_USER: newErrorRenderInfo(
|
||||
"You are not a CSC member :(",
|
||||
"It seems like you're not a CSC member yet. "+
|
||||
`Maybe you'd like to <a href="`+membershipURL+`">become one instead</a>?`),
|
||||
app.ERROR_MEMBERSHIP_EXPIRED: newErrorRenderInfo(
|
||||
"Your membership has expired",
|
||||
`Please visit <a href="`+membershipURL+`">this page</a> for instructions `+
|
||||
"on how to renew your membership."),
|
||||
app.ERROR_OTHER: newErrorRenderInfo(
|
||||
"Something went wrong",
|
||||
"We seem to be having issues on our end. Please contact the "+
|
||||
`<a href="`+syscomURL+`">Systems Committee</a> for assistance.`),
|
||||
app.ERROR_FAILED_TO_SEND_PWRESET_EMAIL: newErrorRenderInfo(
|
||||
"Something went wrong",
|
||||
"We weren't able to send the new password to your email address. Please "+
|
||||
`contact the <a href="`+syscomURL+`">Systems Committee</a> for assistance.`),
|
||||
}
|
||||
|
||||
var apiErrorInfos = map[int]*errorRenderInfo{
|
||||
ERROR_INVALID_CSRF_TOKEN: newErrorRenderInfo(
|
||||
"Invalid CSRF token",
|
||||
"Please go back and refresh the page to get a new token. If this "+
|
||||
`error persists, please contact the <a href="`+syscomURL+`">`+
|
||||
"Systems Committee</a>."),
|
||||
}
|
||||
|
||||
func httpErrorHandler(err error, c echo.Context) {
|
||||
if httpErr, ok := err.(*echo.HTTPError); ok {
|
||||
if httpErr.Code/100 == 4 {
|
||||
renderHTTPErrorPage(c, httpErr.Code, "4xx")
|
||||
return
|
||||
}
|
||||
} else if appErr, ok := err.(app.AppError); ok {
|
||||
renderAppErrorPage(c, appErrorInfos[appErr.Code])
|
||||
return
|
||||
} else if apiErr, ok := err.(ApiError); ok {
|
||||
renderAppErrorPage(c, apiErrorInfos[apiErr.Code])
|
||||
return
|
||||
}
|
||||
c.Logger().Error(err)
|
||||
renderHTTPErrorPage(c, http.StatusInternalServerError, "500")
|
||||
}
|
|
@ -1,93 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/app"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
|
||||
)
|
||||
|
||||
func helmet(cfg *config.Config) echo.MiddlewareFunc {
|
||||
cspSchemes := "https:"
|
||||
if cfg.IsDev {
|
||||
cspSchemes = "http: https:"
|
||||
}
|
||||
cspDirectives := []string{
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"font-src 'self' " + cspSchemes + " data:",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'self'",
|
||||
"img-src 'self' data:",
|
||||
"object-src 'none'",
|
||||
"script-src 'self'",
|
||||
"script-src-attr 'none'",
|
||||
"style-src 'self' " + cspSchemes + " 'unsafe-inline'",
|
||||
}
|
||||
if !cfg.IsDev {
|
||||
cspDirectives = append(cspDirectives, "upgrade-insecure-requests")
|
||||
}
|
||||
csp := strings.Join(cspDirectives, ";")
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
h := c.Response().Header()
|
||||
h.Set(echo.HeaderContentSecurityPolicy, csp)
|
||||
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
h.Set("Cross-Origin-Resource-Policy", "same-origin")
|
||||
h.Set(echo.HeaderReferrerPolicy, "no-referrer")
|
||||
if cfg.HstsMaxAge != 0 {
|
||||
h.Set(
|
||||
echo.HeaderStrictTransportSecurity,
|
||||
"max-age="+strconv.FormatInt(int64(cfg.HstsMaxAge), 10),
|
||||
)
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getReqInfoFromHTTPHeaders(r *http.Request) (*app.ReqInfo, error) {
|
||||
// header names must be in canonical form (see http.CanonicalHeaderKey)
|
||||
usernames := r.Header["X-Csc-Adfs-Username"]
|
||||
if len(usernames) == 0 {
|
||||
return nil, errors.New("Username is missing from HTTP headers")
|
||||
}
|
||||
givenNames := r.Header["X-Csc-Adfs-Firstname"]
|
||||
if len(givenNames) == 0 {
|
||||
return nil, errors.New("Given name is missing from HTTP headers")
|
||||
}
|
||||
return &app.ReqInfo{Username: usernames[0], GivenName: givenNames[0]}, nil
|
||||
}
|
||||
|
||||
type appContext struct {
|
||||
echo.Context
|
||||
req *app.ReqInfo
|
||||
app *app.App
|
||||
}
|
||||
|
||||
func (ac *appContext) Log() logging.Logger {
|
||||
return ac.Context.Logger()
|
||||
}
|
||||
|
||||
func (ac *appContext) Req() *app.ReqInfo {
|
||||
return ac.req
|
||||
}
|
||||
|
||||
func appContextMiddleware(app *app.App) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
reqInfo, err := getReqInfoFromHTTPHeaders(c.Request())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ac := &appContext{Context: c, req: reqInfo, app: app}
|
||||
return next(ac)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/service"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg *config.Config
|
||||
ceod model.CeodService
|
||||
mail service.MailService
|
||||
}
|
||||
|
||||
func NewApp(cfg *config.Config, mailSrv service.MailService) *App {
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
ceod: service.NewCeodService(cfg),
|
||||
mail: mailSrv,
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) GetUser(ctx AppContext, username string) (*model.User, error) {
|
||||
// TODO: cache user lookups
|
||||
cscUser, err := app.ceod.GetUser(ctx, username)
|
||||
if err != nil {
|
||||
if ceodErr, ok := err.(*model.CeodError); ok &&
|
||||
ceodErr.HttpStatus == http.StatusNotFound {
|
||||
return nil, newAppError(ERROR_NO_SUCH_USER)
|
||||
}
|
||||
return nil, newAppError(ERROR_OTHER)
|
||||
}
|
||||
if cscUser.ShadowExpire == 1 {
|
||||
return nil, newAppError(ERROR_MEMBERSHIP_EXPIRED)
|
||||
}
|
||||
return cscUser, nil
|
||||
}
|
||||
|
||||
func (app *App) GetReqUser(ctx AppContext) (*model.User, error) {
|
||||
return app.GetUser(ctx, ctx.Req().Username)
|
||||
}
|
||||
|
||||
type ReqInfo struct {
|
||||
// This info comes from ADFS via SAML
|
||||
Username string
|
||||
GivenName string
|
||||
}
|
||||
|
||||
type AppContext interface {
|
||||
logging.ContextWithLogger
|
||||
Req() *ReqInfo
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
package app
|
||||
|
||||
const (
|
||||
ERROR_NO_SUCH_USER = iota + 1
|
||||
ERROR_MEMBERSHIP_EXPIRED
|
||||
ERROR_FAILED_TO_SEND_PWRESET_EMAIL
|
||||
ERROR_OTHER
|
||||
)
|
||||
|
||||
type AppError struct {
|
||||
Code int
|
||||
}
|
||||
|
||||
func newAppError(code int) AppError {
|
||||
return AppError{Code: code}
|
||||
}
|
||||
|
||||
func (a AppError) Error() string {
|
||||
// This is just a placeholder; the real error message should be
|
||||
// provided by the presentation layer
|
||||
return "App error"
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
|
||||
)
|
||||
|
||||
// PwresetCheck checks that the client is allowed to reset their password
|
||||
// and returns the email address to which the new password would be sent.
|
||||
func (a *App) PwresetCheck(ctx AppContext) (string, error) {
|
||||
user, err := a.GetReqUser(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return a.getUWEmailAddress(user), nil
|
||||
}
|
||||
|
||||
// Pwreset returns the email address to which the new password was sent
|
||||
func (a *App) Pwreset(ctx AppContext) (string, error) {
|
||||
user, err := a.GetReqUser(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
newPassword, err := a.ceod.Pwreset(ctx, user.Uid)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
emailAddress := a.getUWEmailAddress(user)
|
||||
err = a.mail.Send(ctx, user.Cn, emailAddress, "pwreset_email.txt", map[string]any{
|
||||
"firstName": user.GivenName,
|
||||
"newPassword": newPassword,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.Log().Errorf("Failed to send new password email: %w", err)
|
||||
return "", newAppError(ERROR_FAILED_TO_SEND_PWRESET_EMAIL)
|
||||
}
|
||||
return emailAddress, nil
|
||||
}
|
||||
|
||||
func (a *App) getUWEmailAddress(user *model.User) string {
|
||||
suffix := "@" + a.cfg.UWDomain
|
||||
for _, address := range user.ForwardingAddresses {
|
||||
if strings.HasSuffix(address, suffix) {
|
||||
return address
|
||||
}
|
||||
}
|
||||
// Fallback
|
||||
return user.Uid + suffix
|
||||
}
|
|
@ -1,135 +0,0 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultKrb5Config = "/etc/krb5.conf"
|
||||
defaultKrb5KtName = "/etc/krb5.keytab"
|
||||
// The cookie name needs to be very unique because this app is
|
||||
// being served from the root CSC domain
|
||||
defaultCookieName = "ceod-web-csrf"
|
||||
ceodHostname = "phosphoric-acid"
|
||||
ceodPort = 9987
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SocketPath string `json:"socket_path"`
|
||||
NoSocketAuth bool `json:"no_socket_auth"`
|
||||
Krb5ConfigPath string `json:"-"`
|
||||
Krb5KeytabPath string `json:"-"`
|
||||
AppURL string `json:"app_url"`
|
||||
CSCDomain string `json:"csc_domain"`
|
||||
UWDomain string `json:"uw_domain"`
|
||||
MTA string `json:"mta"`
|
||||
CookieName string `json:"cookie_name"`
|
||||
HstsMaxAge int `json:"hsts_max_age"`
|
||||
IsDev bool `json:"dev"`
|
||||
ForcedEmailRecipient string `json:"forced_email_recipient"`
|
||||
|
||||
appHostname string
|
||||
appPath string
|
||||
}
|
||||
|
||||
func envGet(name, defaultValue string) string {
|
||||
if s := os.Getenv(name); s != "" {
|
||||
return s
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func NewConfig(cfgPath string) *Config {
|
||||
cfg := new(Config)
|
||||
file, err := os.Open(cfgPath)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("Could not open %s: %w", cfgPath, err))
|
||||
}
|
||||
defer file.Close()
|
||||
if err = json.NewDecoder(file).Decode(cfg); err != nil {
|
||||
panic(fmt.Errorf("Could not decode JSON from %s: %w", cfgPath, err))
|
||||
}
|
||||
if cfg.SocketPath == "" {
|
||||
panic(fmt.Errorf(`"socket_path" is missing from %s`, cfgPath))
|
||||
}
|
||||
cfg.Krb5ConfigPath = envGet("KRB5_CONFIG", defaultKrb5Config)
|
||||
cfg.Krb5KeytabPath = envGet("KRB5_KTNAME", defaultKrb5KtName)
|
||||
if cfg.CSCDomain == "" {
|
||||
panic(fmt.Errorf(`"csc_domain" is missing from %s`, cfgPath))
|
||||
}
|
||||
if cfg.UWDomain == "" {
|
||||
panic(fmt.Errorf(`"uw_domain" is missing from %s`, cfgPath))
|
||||
}
|
||||
if cfg.AppURL == "" {
|
||||
panic(fmt.Errorf(`"app_url" is missing from %s`, cfgPath))
|
||||
}
|
||||
appURL := cfg.AppURL
|
||||
if !strings.HasPrefix(appURL, "http://") && !strings.HasPrefix(appURL, "https://") {
|
||||
appURL = "//" + appURL
|
||||
}
|
||||
parsedURL, err := url.Parse(appURL)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf(`Could not parse URL "%s": %w`, appURL, err))
|
||||
}
|
||||
cfg.appHostname = parsedURL.Hostname()
|
||||
if cfg.appHostname == "" {
|
||||
panic(fmt.Errorf(`Could not parse URL "%s"`, appURL))
|
||||
}
|
||||
cfg.appPath = parsedURL.Path
|
||||
if cfg.appPath == "" {
|
||||
cfg.appPath = "/"
|
||||
} else if cfg.appPath[len(cfg.appPath)-1] != '/' {
|
||||
cfg.appPath += "/"
|
||||
}
|
||||
if cfg.CookieName == "" {
|
||||
cfg.CookieName = defaultCookieName
|
||||
}
|
||||
if !cfg.IsDev && cfg.MTA == "" {
|
||||
panic(fmt.Errorf(`"mta" is missing from %s`, cfgPath))
|
||||
}
|
||||
if !cfg.IsDev && cfg.ForcedEmailRecipient != "" {
|
||||
panic(fmt.Errorf(`"forced_email_recipient" may only be used in development`))
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func getHostnameOrDie() string {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("Failed to get hostname: %w", err))
|
||||
}
|
||||
return hostname
|
||||
}
|
||||
|
||||
func (c *Config) GetCeodBaseURL() string {
|
||||
scheme := "https"
|
||||
if c.IsDev {
|
||||
scheme = "http"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s.%s:%d", scheme, ceodHostname, c.CSCDomain, ceodPort)
|
||||
}
|
||||
|
||||
func (c *Config) GetKrb5Principal() string {
|
||||
hostname := getHostnameOrDie()
|
||||
return fmt.Sprintf("ceod/%s.%s", hostname, c.CSCDomain)
|
||||
}
|
||||
|
||||
func (c *Config) GetKrb5Realm() string {
|
||||
return strings.ToUpper(c.CSCDomain)
|
||||
}
|
||||
|
||||
func (c *Config) GetCeodSPN() string {
|
||||
return fmt.Sprintf("ceod/%s.%s", ceodHostname, c.CSCDomain)
|
||||
}
|
||||
|
||||
func (c *Config) GetAppHostname() string {
|
||||
return c.appHostname
|
||||
}
|
||||
|
||||
func (c *Config) GetAppPath() string {
|
||||
return c.appPath
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
package internal
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed views/*
|
||||
var EmbeddedViews embed.FS
|
||||
|
||||
//go:embed static/*
|
||||
var EmbeddedAssets embed.FS
|
|
@ -1,213 +0,0 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
krb5client "github.com/jcmturner/gokrb5/v8/client"
|
||||
krb5config "github.com/jcmturner/gokrb5/v8/config"
|
||||
"github.com/jcmturner/gokrb5/v8/keytab"
|
||||
"github.com/jcmturner/gokrb5/v8/spnego"
|
||||
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
|
||||
)
|
||||
|
||||
type Ceod struct {
|
||||
apiBaseURL string
|
||||
spnegoClient *spnego.Client
|
||||
}
|
||||
|
||||
func NewCeodService(cfg *config.Config) model.CeodService {
|
||||
krb5cfg, err := krb5config.Load(cfg.Krb5ConfigPath)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("Failed to load %s: %w", cfg.Krb5ConfigPath, err))
|
||||
}
|
||||
kt, err := keytab.Load(cfg.Krb5KeytabPath)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("Failed to load %s: %w", cfg.Krb5KeytabPath, err))
|
||||
}
|
||||
cl := krb5client.NewWithKeytab(cfg.GetKrb5Principal(), cfg.GetKrb5Realm(), kt, krb5cfg)
|
||||
err = cl.Login()
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("Failed to login to KDC: %w", err))
|
||||
}
|
||||
return &Ceod{
|
||||
apiBaseURL: cfg.GetCeodBaseURL(),
|
||||
spnegoClient: spnego.NewClient(cl, nil, cfg.GetCeodSPN()),
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: auto-generate these from the OpenAPI definition
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type PwresetResponse struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (c *Ceod) getResp(ctx model.CeodRequestContext, method, urlPath string, body any) (*http.Response, error) {
|
||||
ctx.Log().Debugf("%s %s", method, urlPath)
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not marshal body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewBuffer(buf)
|
||||
}
|
||||
r, err := http.NewRequest(method, c.apiBaseURL+urlPath, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not build HTTP request: %w", err)
|
||||
}
|
||||
if bodyReader != nil {
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.spnegoClient.Do(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SPNEGO request failed: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func getRespError(ctx model.CeodRequestContext, resp *http.Response) error {
|
||||
if resp.StatusCode/100 == 2 {
|
||||
return nil
|
||||
}
|
||||
rawBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"HTTP status=%d, and failed to read body: %w",
|
||||
resp.StatusCode, err,
|
||||
)
|
||||
}
|
||||
var errBody ErrorResponse
|
||||
err = json.Unmarshal(rawBody, &errBody)
|
||||
var message string
|
||||
if err != nil {
|
||||
message = string(rawBody)
|
||||
} else {
|
||||
message = errBody.Error
|
||||
}
|
||||
ctx.Log().Infof("ceod response: status=%d message=%s", resp.StatusCode, message)
|
||||
return &model.CeodError{HttpStatus: resp.StatusCode, Message: message}
|
||||
}
|
||||
|
||||
func (c *Ceod) request(ctx model.CeodRequestContext, method, urlPath string, respBody any) error {
|
||||
resp, err := c.getResp(ctx, method, urlPath, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
err = getRespError(ctx, resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(respBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to decode JSON response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseTransactionMessage[T any](b []byte) (model.ITransactionMessage, error) {
|
||||
var txn model.TransactionMessage
|
||||
if err := json.Unmarshal(b, &txn); err != nil {
|
||||
return nil, fmt.Errorf("Could not decode JSON: '%s'", string(b))
|
||||
}
|
||||
var itxn model.ITransactionMessage
|
||||
switch txn.Status {
|
||||
case "in progress":
|
||||
itxn = new(model.TransactionInProgress)
|
||||
case "completed":
|
||||
itxn = new(model.TransactionCompleted[T])
|
||||
case "aborted":
|
||||
itxn = new(model.TransactionAborted)
|
||||
default:
|
||||
return nil, fmt.Errorf("Unrecognized transaction status '%s'", txn.Status)
|
||||
}
|
||||
if err := json.Unmarshal(b, itxn); err != nil {
|
||||
return nil, fmt.Errorf("Could not decode JSON: '%s'", string(b))
|
||||
}
|
||||
return itxn, nil
|
||||
}
|
||||
|
||||
func requestTransaction[T any](
|
||||
c *Ceod, ctx model.CeodTransactionRequestContext,
|
||||
method, urlPath string, body any,
|
||||
) (<-chan model.ITransactionMessage, error) {
|
||||
resp, err := c.getResp(ctx, method, urlPath, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = getRespError(ctx, resp)
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
txnChan := make(chan model.ITransactionMessage)
|
||||
go func() {
|
||||
defer resp.Body.Close()
|
||||
defer close(txnChan)
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
msg, err := parseTransactionMessage[T](scanner.Bytes())
|
||||
if err != nil {
|
||||
ctx.Log().Error(err)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case txnChan <- msg:
|
||||
continue
|
||||
case <-ctx.CancelChan():
|
||||
ctx.Log().Warn("Cancel chan closed, returning")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
ctx.Log().Error(err)
|
||||
}
|
||||
}()
|
||||
return txnChan, nil
|
||||
}
|
||||
|
||||
func (c *Ceod) GetUser(ctx model.CeodRequestContext, username string) (*model.User, error) {
|
||||
user := &model.User{}
|
||||
err := c.request(ctx, "GET", "/api/members/"+username, user)
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (c *Ceod) GetUWUser(ctx model.CeodRequestContext, username string) (*model.UWUser, error) {
|
||||
uwUser := &model.UWUser{}
|
||||
err := c.request(ctx, "GET", "/api/uwldap/"+username, uwUser)
|
||||
return uwUser, err
|
||||
}
|
||||
|
||||
func (c *Ceod) Pwreset(ctx model.CeodRequestContext, username string) (string, error) {
|
||||
resp := &PwresetResponse{}
|
||||
err := c.request(ctx, "POST", "/api/members/"+username+"/pwreset", resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Password, nil
|
||||
}
|
||||
|
||||
func (c *Ceod) AddUser(
|
||||
ctx model.CeodTransactionRequestContext,
|
||||
req *model.AddUserRequest,
|
||||
) (<-chan model.ITransactionMessage, error) {
|
||||
return requestTransaction[model.UserWithPassword](c, ctx, "POST", "/api/members", req)
|
||||
}
|
||||
|
||||
func (c *Ceod) DeleteUser(
|
||||
ctx model.CeodTransactionRequestContext,
|
||||
username string,
|
||||
) (<-chan model.ITransactionMessage, error) {
|
||||
return requestTransaction[string](c, ctx, "DELETE", "/api/members/"+username, nil)
|
||||
}
|
|
@ -1,129 +0,0 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
|
||||
)
|
||||
|
||||
type MailService interface {
|
||||
Send(
|
||||
ctx logging.ContextWithLogger,
|
||||
recipientName, recipientAddress, tmplName string,
|
||||
data map[string]any,
|
||||
) error
|
||||
}
|
||||
|
||||
type MailBackend interface {
|
||||
Send(addr string, from string, to []string, msg []byte) error
|
||||
}
|
||||
|
||||
type mail struct {
|
||||
cfg *config.Config
|
||||
backend MailBackend
|
||||
}
|
||||
|
||||
type prodMailBackend struct{}
|
||||
type devMailBackend struct{}
|
||||
|
||||
//go:embed pwreset_email.txt
|
||||
var emailTemplateFiles embed.FS
|
||||
|
||||
var emailTemplates = make(map[string]*template.Template)
|
||||
|
||||
func init() {
|
||||
templateFiles := []string{"pwreset_email.txt"}
|
||||
for _, filename := range templateFiles {
|
||||
contentBytes, err := emailTemplateFiles.ReadFile(filename)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
contentStr := string(contentBytes)
|
||||
// The lines of the body need to be CRLF terminated
|
||||
// See https://pkg.go.dev/net/smtp#SendMail
|
||||
if strings.Contains(contentStr, "\r") {
|
||||
panic(fmt.Errorf("File %s should not have carriage returns", filename))
|
||||
}
|
||||
contentStr = strings.ReplaceAll(contentStr, "\n", "\r\n")
|
||||
emailTemplates[filename] = template.Must(template.New(filename).Parse(contentStr))
|
||||
}
|
||||
}
|
||||
|
||||
func NewMailServiceWithBackend(cfg *config.Config, backend MailBackend) MailService {
|
||||
return &mail{cfg, backend}
|
||||
}
|
||||
|
||||
func NewMailService(cfg *config.Config) MailService {
|
||||
var backend MailBackend
|
||||
if cfg.IsDev && cfg.ForcedEmailRecipient == "" {
|
||||
backend = devMailBackend{}
|
||||
} else {
|
||||
backend = prodMailBackend{}
|
||||
}
|
||||
return NewMailServiceWithBackend(cfg, backend)
|
||||
}
|
||||
|
||||
func (m *mail) senderAddress() string {
|
||||
return "ceod+web@" + m.cfg.CSCDomain
|
||||
}
|
||||
|
||||
func (m *mail) render(
|
||||
recipientName, recipientAddress, tmplName string,
|
||||
data map[string]any,
|
||||
) ([]byte, error) {
|
||||
tmpl, ok := emailTemplates[tmplName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("No such email template '%s'", tmplName)
|
||||
}
|
||||
data["emailSenderName"] = "CSC Electronic Office"
|
||||
data["emailSender"] = m.senderAddress()
|
||||
data["emailRecipientName"] = recipientName
|
||||
data["emailRecipient"] = recipientAddress
|
||||
data["emailReplyTo"] = "no-reply@" + m.cfg.CSCDomain
|
||||
data["emailDate"] = time.Now().Format(time.RFC1123Z)
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return nil, fmt.Errorf("Failed to render template %s: %w", tmplName, err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (m *mail) Send(
|
||||
ctx logging.ContextWithLogger,
|
||||
recipientName, recipientAddress, tmplName string,
|
||||
data map[string]any,
|
||||
) error {
|
||||
msg, err := m.render(recipientName, recipientAddress, tmplName, data)
|
||||
if err != nil {
|
||||
ctx.Log().Error(err)
|
||||
return err
|
||||
}
|
||||
ctx.Log().Debugf("Sending email to %s", recipientAddress)
|
||||
realRecipientAddress := recipientAddress
|
||||
if m.cfg.ForcedEmailRecipient != "" {
|
||||
realRecipientAddress = m.cfg.ForcedEmailRecipient
|
||||
}
|
||||
return m.backend.Send(m.cfg.MTA, m.senderAddress(), []string{realRecipientAddress}, msg)
|
||||
}
|
||||
|
||||
func (m devMailBackend) Send(addr string, from string, to []string, msg []byte) error {
|
||||
fmt.Printf("Would have sent this email:\n%s", string(msg))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m prodMailBackend) Send(addr string, from string, to []string, msg []byte) error {
|
||||
return smtp.SendMail(
|
||||
addr,
|
||||
nil, // auth
|
||||
from,
|
||||
to,
|
||||
msg,
|
||||
)
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
From: {{ .emailSenderName }} <{{ .emailSender }}>
|
||||
To: {{ .emailRecipientName }} <{{ .emailRecipient }}>
|
||||
Reply-To: {{ .emailReplyTo }}
|
||||
Subject: Computer Science Club Password Reset
|
||||
Date: {{ .emailDate }}
|
||||
|
||||
Hello {{ .firstName }},
|
||||
|
||||
Your new temporary CSC password is:
|
||||
|
||||
{{ .newPassword }}
|
||||
|
||||
Please SSH into any general-use machine and change this password (you
|
||||
will be prompted to do so when you login). Here are instructions on
|
||||
how to do so:
|
||||
|
||||
https://wiki.csclub.uwaterloo.ca/How_to_SSH
|
||||
|
||||
If you have any questions or concerns, please contact
|
||||
syscom@csclub.uwaterloo.ca.
|
||||
|
||||
Regards,
|
||||
ceo
|
|
@ -1,7 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="574px" height="252px" viewBox="0 0 574 252" preserveAspectRatio="xMidYMid meet">
|
||||
<g fill="#000000">
|
||||
<path d="M112.3 251 c-53.3 -5.6 -96.7 -43.8 -109.3 -96 -3.2 -13.1 -3.9 -36.8 -1.6 -50.6 10.6 -62.2 65.8 -106.8 128.8 -104.1 24.3 1 45.8 7.9 65.7 21.1 7.4 5 18.5 14.3 18.9 16 0.1 0.6 -2.7 2 -6.4 3 -29.7 8.5 -53.3 33 -61.6 64.1 -1.7 6.3 -2.2 10.9 -2.2 21.5 -0.1 11.9 0.3 14.6 2.7 23 4.9 16.6 15.7 33.8 27.2 43.3 l4.6 3.9 4.5 -3.8 c19 -15.5 31.4 -44.2 31.4 -72.7 0 -14.8 5.1 -35 12.7 -50.2 21.4 -42.6 64.8 -69.4 112.3 -69.5 16.3 0 35.6 3.7 48.4 9.4 l5.5 2.4 7.2 -2.9 c8.7 -3.6 19.6 -6.4 30.8 -8 10.4 -1.5 31.7 -0.6 42.1 1.6 46.8 10.3 83.5 45.2 95.5 91 8 30.3 4.6 61.9 -9.8 89.9 -17.5 34.2 -48.5 57.6 -87 65.7 -11.8 2.5 -32.8 3 -44.3 1.1 -23.8 -4 -45.5 -14.3 -65.1 -31 -2.9 -2.4 -5.3 -4.7 -5.3 -5.1 0 -0.4 3.3 -1.8 7.3 -3 36.4 -11.7 60.2 -41.6 64.1 -80.6 1.8 -17.9 -7.2 -44.5 -20.4 -60.7 -4.9 -6 -13.4 -13.8 -15 -13.8 -1.5 0 -11 9.2 -15.8 15.3 -6.7 8.6 -11.5 17.7 -15.1 29 -2.5 7.9 -3.2 12.4 -4.1 25.4 -1.3 19.1 -2.2 24.8 -6.1 37.8 -19.5 63.6 -86.9 101 -151.7 84 -5.1 -1.4 -12.2 -3.6 -15.6 -5 l-6.3 -2.5 -8.9 3.4 c-18.6 7 -38.5 9.6 -58.1 7.6z m28.1 -38 c0.5 -0.5 -0.9 -2.9 -3.7 -6 -21.4 -23.8 -33 -62.5 -28.7 -95.6 3.2 -25 16.2 -53.7 31.7 -70.6 l2.5 -2.6 -6.8 -0.7 c-42 -4.5 -82.6 22.3 -94.7 62.5 -5.7 19 -5.6 33.8 0.3 52.4 10.5 32.4 38.5 56.2 72.8 61.6 6.7 1 25.2 0.4 26.6 -1z m103.6 1 c16.8 -2.7 32.7 -9.7 45.1 -19.8 20.2 -16.4 32.8 -43.2 32.9 -69.7 0.1 -28.2 11.1 -57.7 29.8 -80.2 l5.1 -6.1 -7.6 -0.8 c-35.5 -3.3 -69.8 15.1 -87 46.6 -6.9 12.8 -11.3 30.8 -11.3 46.8 0 25.2 -11.3 54.6 -29.3 76.2 -2.5 3 -4.4 5.7 -4.1 5.9 1.8 1.9 17.5 2.5 26.4 1.1z m215 0 c41.5 -6.6 72 -37.7 77.2 -78.7 2.1 -16.3 -1.7 -35.3 -10.2 -51 -17.2 -32 -51.3 -50.2 -87.3 -46.8 l-7.7 0.7 4.5 5 c13.1 14.5 23.4 35.2 28 55.8 2.2 10.1 3.1 32.7 1.6 43.2 -3.1 21.6 -12.9 44.7 -26.5 62.3 -4.2 5.4 -6.2 8.7 -5.4 9 4.3 1.5 17.9 1.7 25.8 0.5z"/>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 2.1 KiB |
|
@ -1,349 +0,0 @@
|
|||
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
|
||||
|
||||
/* Document
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Correct the line height in all browsers.
|
||||
* 2. Prevent adjustments of font size after orientation changes in iOS.
|
||||
*/
|
||||
|
||||
html {
|
||||
line-height: 1.15; /* 1 */
|
||||
-webkit-text-size-adjust: 100%; /* 2 */
|
||||
}
|
||||
|
||||
/* Sections
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the margin in all browsers.
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the `main` element consistently in IE.
|
||||
*/
|
||||
|
||||
main {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the font size and margin on `h1` elements within `section` and
|
||||
* `article` contexts in Chrome, Firefox, and Safari.
|
||||
*/
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
|
||||
/* Grouping content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in Firefox.
|
||||
* 2. Show the overflow in Edge and IE.
|
||||
*/
|
||||
|
||||
hr {
|
||||
box-sizing: content-box; /* 1 */
|
||||
height: 0; /* 1 */
|
||||
overflow: visible; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
pre {
|
||||
font-family: monospace, monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/* Text-level semantics
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the gray background on active links in IE 10.
|
||||
*/
|
||||
|
||||
a {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Remove the bottom border in Chrome 57-
|
||||
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
|
||||
*/
|
||||
|
||||
abbr[title] {
|
||||
border-bottom: none; /* 1 */
|
||||
text-decoration: underline; /* 2 */
|
||||
text-decoration: underline dotted; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font weight in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: monospace, monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent `sub` and `sup` elements from affecting the line height in
|
||||
* all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/* Embedded content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the border on images inside links in IE 10.
|
||||
*/
|
||||
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
/* Forms
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Change the font styles in all browsers.
|
||||
* 2. Remove the margin in Firefox and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit; /* 1 */
|
||||
font-size: 100%; /* 1 */
|
||||
line-height: 1.15; /* 1 */
|
||||
margin: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the overflow in IE.
|
||||
* 1. Show the overflow in Edge.
|
||||
*/
|
||||
|
||||
button,
|
||||
input { /* 1 */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inheritance of text transform in Edge, Firefox, and IE.
|
||||
* 1. Remove the inheritance of text transform in Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select { /* 1 */
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the inability to style clickable types in iOS and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
[type="button"],
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner border and padding in Firefox.
|
||||
*/
|
||||
|
||||
button::-moz-focus-inner,
|
||||
[type="button"]::-moz-focus-inner,
|
||||
[type="reset"]::-moz-focus-inner,
|
||||
[type="submit"]::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the focus styles unset by the previous rule.
|
||||
*/
|
||||
|
||||
button:-moz-focusring,
|
||||
[type="button"]:-moz-focusring,
|
||||
[type="reset"]:-moz-focusring,
|
||||
[type="submit"]:-moz-focusring {
|
||||
outline: 1px dotted ButtonText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the padding in Firefox.
|
||||
*/
|
||||
|
||||
fieldset {
|
||||
padding: 0.35em 0.75em 0.625em;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the text wrapping in Edge and IE.
|
||||
* 2. Correct the color inheritance from `fieldset` elements in IE.
|
||||
* 3. Remove the padding so developers are not caught out when they zero out
|
||||
* `fieldset` elements in all browsers.
|
||||
*/
|
||||
|
||||
legend {
|
||||
box-sizing: border-box; /* 1 */
|
||||
color: inherit; /* 2 */
|
||||
display: table; /* 1 */
|
||||
max-width: 100%; /* 1 */
|
||||
padding: 0; /* 3 */
|
||||
white-space: normal; /* 1 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
|
||||
*/
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the default vertical scrollbar in IE 10+.
|
||||
*/
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in IE 10.
|
||||
* 2. Remove the padding in IE 10.
|
||||
*/
|
||||
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
box-sizing: border-box; /* 1 */
|
||||
padding: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the cursor style of increment and decrement buttons in Chrome.
|
||||
*/
|
||||
|
||||
[type="number"]::-webkit-inner-spin-button,
|
||||
[type="number"]::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the odd appearance in Chrome and Safari.
|
||||
* 2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
outline-offset: -2px; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner padding in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inability to style clickable types in iOS and Safari.
|
||||
* 2. Change font properties to `inherit` in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/* Interactive
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Add the correct display in Edge, IE 10+, and Firefox.
|
||||
*/
|
||||
|
||||
details {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/*
|
||||
* Add the correct display in all browsers.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/* Misc
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 10+.
|
||||
*/
|
||||
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 10.
|
||||
*/
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
|
@ -1,67 +0,0 @@
|
|||
*, ::after, ::before {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
padding: 0.2rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",sans-serif;
|
||||
background-color: var(--background-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background-primary: #fff;
|
||||
--text-primary: #1b1b1b;
|
||||
--text-link: #0069c2;
|
||||
--text-visited: #551a8b;
|
||||
--accent-primary: #0085f2;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background-primary: #1b1b1b;
|
||||
--text-primary: #fff;
|
||||
--text-link: #8cb4ff;
|
||||
--text-visited: #ffadff;
|
||||
--accent-primary: #5e9eff;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-link);
|
||||
}
|
||||
|
||||
a:active, a:active:visited {
|
||||
background-color: var(--text-link);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: var(--text-visited);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline-color: var(--accent-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
input[type="submit"]:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.mt-10 { margin-top: 2.5rem; }
|
||||
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
|
|
@ -1,7 +0,0 @@
|
|||
{{ define "title" }}{{ .statusText }}{{ end }}
|
||||
{{ define "main" }}
|
||||
<p>
|
||||
Please contact the <a href="mailto:syscom@csclub.uwaterloo.ca">Systems Committee</a>
|
||||
if you are in need of assistance.
|
||||
</p>
|
||||
{{ end }}
|
|
@ -1,7 +0,0 @@
|
|||
{{ define "title" }}Internal server error{{ end }}
|
||||
{{ define "main" }}
|
||||
<p>
|
||||
Oops! Please contact the <a href="mailto:syscom@csclub.uwaterloo.ca">Systems Committee</a>
|
||||
for assistance.
|
||||
</p>
|
||||
{{ end }}
|
|
@ -1,4 +0,0 @@
|
|||
{{ define "title" }}{{ .Title }}{{ end }}
|
||||
{{ define "main" }}
|
||||
<p>{{ .HtmlFragment }}</p>
|
||||
{{ end }}
|
|
@ -1,20 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="static/csc-logo.svg">
|
||||
<link rel="stylesheet" href="static/normalize.css">
|
||||
<link rel="stylesheet" href="static/styles.css">
|
||||
<title>{{ block "title" . }}CSC Electronic Office{{ end }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>{{ template "title" . }}</h1>
|
||||
<hr>
|
||||
</header>
|
||||
<main>{{ block "main" . }}{{ end }}</main>
|
||||
<footer>
|
||||
Copyright © 2024 Computer Science Club of the University of Waterloo
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
|
@ -1,26 +0,0 @@
|
|||
{{ define "title" }}Password Reset{{ end }}
|
||||
{{ define "main" }}
|
||||
<p>
|
||||
<strong>NOTE</strong>: if you are able to SSH into a general-use machine
|
||||
using a public key, then you do not need to use this form. Simply run
|
||||
<code>passwd</code> instead.
|
||||
</p>
|
||||
<p>
|
||||
Clicking the button below will generate a new temporary password for your
|
||||
account which will be sent to the following email address:
|
||||
</p>
|
||||
<p>
|
||||
<strong>{{ .emailAddress }}</strong>
|
||||
</p>
|
||||
<p>
|
||||
If you do not have access to the email address above, please contact the
|
||||
<a href="mailto:syscom@csclub.uwaterloo.ca">Systems Committee</a> for assistance.
|
||||
</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ .csrf }}" />
|
||||
<input type="submit" class="py-2 px-6 text-lg" value="Reset my password" />
|
||||
</form>
|
||||
<p class="mt-10">
|
||||
<a href=".">Return home</a>
|
||||
</p>
|
||||
{{ end }}
|
|
@ -1,14 +0,0 @@
|
|||
{{ define "title" }}Password Reset Confirmation{{ end }}
|
||||
{{ define "main" }}
|
||||
<p>
|
||||
A new temporary password was sent to <b>{{ .emailAddress }}</b>.
|
||||
</p>
|
||||
<p>
|
||||
PLEASE CHECK YOUR JUNK FOLDER. MS Outlook has an unfortunate tendency to place
|
||||
our emails there. If you still have not received the confirmation email after
|
||||
20 minutes, please contact the <a href="mailto:syscom@csclub.uwaterloo.ca">Systems Committee</a>.
|
||||
</p>
|
||||
<p class="mt-10">
|
||||
<a href=".">Return home</a>
|
||||
</p>
|
||||
{{ end }}
|
|
@ -1,11 +0,0 @@
|
|||
{{ define "title" }}CSC Electronic Office{{ end }}
|
||||
{{ define "main" }}
|
||||
<p>
|
||||
Hello, {{ .firstName }}. What would you like to do?
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="pwreset">Reset password</a>
|
||||
</li>
|
||||
</ul>
|
||||
{{ end }}
|
18
web/main.go
18
web/main.go
|
@ -1,18 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/api"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("c", "", "config file")
|
||||
flag.Parse()
|
||||
if *configPath == "" {
|
||||
panic("Config file must be specified")
|
||||
}
|
||||
cfg := config.NewConfig(*configPath)
|
||||
api.Start(cfg)
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
package logging
|
||||
|
||||
type Logger interface {
|
||||
Debug(i ...interface{})
|
||||
Debugf(format string, args ...interface{})
|
||||
Info(i ...interface{})
|
||||
Infof(format string, args ...interface{})
|
||||
Warn(i ...interface{})
|
||||
Warnf(format string, args ...interface{})
|
||||
Error(i ...interface{})
|
||||
Errorf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
type ContextWithLogger interface {
|
||||
Log() Logger
|
||||
}
|
|
@ -1,115 +0,0 @@
|
|||
package model
|
||||
|
||||
import "git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
|
||||
|
||||
// TODO: auto-generate using OpenAPI definition
|
||||
|
||||
type CeodError struct {
|
||||
HttpStatus int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *CeodError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
type CeodRequestContext logging.ContextWithLogger
|
||||
|
||||
type CeodTransactionRequestContext interface {
|
||||
logging.ContextWithLogger
|
||||
CancelChan() <-chan struct{}
|
||||
}
|
||||
|
||||
type CeodService interface {
|
||||
GetUser(ctx CeodRequestContext, username string) (*User, error)
|
||||
GetUWUser(ctx CeodRequestContext, username string) (*UWUser, error)
|
||||
Pwreset(ctx CeodRequestContext, username string) (string, error)
|
||||
// On success, last message will be TransactionCompleted[UserWithPassword]
|
||||
AddUser(ctx CeodTransactionRequestContext, req *AddUserRequest) (<-chan ITransactionMessage, error)
|
||||
// On success, last message will be string "OK"
|
||||
DeleteUser(ctx CeodTransactionRequestContext, username string) (<-chan ITransactionMessage, error)
|
||||
}
|
||||
|
||||
type AddUserRequest struct {
|
||||
// Full name
|
||||
Cn string `json:"cn"`
|
||||
// Last name
|
||||
Sn string `json:"sn"`
|
||||
// First name
|
||||
GivenName string `json:"given_name"`
|
||||
// Username
|
||||
Uid string `json:"uid"`
|
||||
Program string `json:"program"`
|
||||
Terms int `json:"terms,omitempty"`
|
||||
NonMemberTerms int `json:"non_member_terms,omitempty"`
|
||||
ForwardingAddresses []string `json:"forwarding_addresses"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
// Full name
|
||||
Cn string `json:"cn"`
|
||||
// Last name
|
||||
Sn string `json:"sn"`
|
||||
// First name
|
||||
GivenName string `json:"given_name"`
|
||||
// Username
|
||||
Uid string `json:"uid"`
|
||||
UidNumber int `json:"uid_number"`
|
||||
GidNumber int `json:"gid_number"`
|
||||
HomeDirectory string `json:"home_directory"`
|
||||
LoginShell string `json:"login_shell"`
|
||||
Groups []string `json:"groups"`
|
||||
Program string `json:"program"`
|
||||
// Terms will be absent for club reps
|
||||
Terms []string `json:"terms,omitempty"`
|
||||
// NonMemberTerms will be absent for general members
|
||||
NonMemberTerms []string `json:"non_member_terms,omitempty"`
|
||||
MailLocalAddresses []string `json:"mail_local_addresses"`
|
||||
// ForwardingAddresses will be absent if the client does not have
|
||||
// sufficient permissions. It will be empty if the client's
|
||||
// ~/.forward is missing or empty.
|
||||
ForwardingAddresses []string `json:"forwarding_addresses,omitempty"`
|
||||
IsClub bool `json:"is_club"`
|
||||
IsClubRep bool `json:"is_club_rep"`
|
||||
// ShadowExpire will be 1 if the user's account is expired, and
|
||||
// absent otherwise.
|
||||
ShadowExpire int `json:"shadow_expire,omitempty"`
|
||||
}
|
||||
|
||||
type UWUser struct {
|
||||
// Username
|
||||
Uid string `json:"uid"`
|
||||
MailLocalAddresses []string `json:"mail_local_addresses"`
|
||||
// The following fields might be absent for alumni
|
||||
Cn string `json:"cn,omitempty"`
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
Sn string `json:"sn,omitempty"`
|
||||
// This field will always be absent for alumni
|
||||
Program string `json:"program,omitempty"`
|
||||
}
|
||||
|
||||
type ITransactionMessage interface{}
|
||||
|
||||
type TransactionMessage struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type TransactionInProgress struct {
|
||||
TransactionMessage // "in progress"
|
||||
Operation string `json:"operation"`
|
||||
}
|
||||
|
||||
type TransactionAborted struct {
|
||||
TransactionMessage // "aborted"
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type TransactionCompleted[T any] struct {
|
||||
TransactionMessage // "completed"
|
||||
Result T `json:"result"`
|
||||
}
|
||||
|
||||
type UserWithPassword struct {
|
||||
User
|
||||
Password string `json:"password"`
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"app_url": "https://csclub.uwaterloo.ca/ceo",
|
||||
"socket_path": "/run/ceod-web/app.sock",
|
||||
"csc_domain": "csclub.uwaterloo.ca",
|
||||
"uw_domain": "uwaterloo.ca",
|
||||
"mta": "mail.csclub.uwaterloo.ca:25"
|
||||
}
|
|
@ -1,75 +0,0 @@
|
|||
package main
|
||||
|
||||
// Adapted from https://gist.github.com/yowu/f7dc34bd4736a65ff28d
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
var (
|
||||
username string
|
||||
firstName string
|
||||
port int
|
||||
sockPath string
|
||||
)
|
||||
|
||||
func dial(proto, addr string) (net.Conn, error) {
|
||||
return net.Dial("unix", sockPath)
|
||||
}
|
||||
|
||||
func newClient() *http.Client {
|
||||
transport := &http.Transport{Dial: dial}
|
||||
return &http.Client{Transport: transport}
|
||||
}
|
||||
|
||||
type Proxy struct{}
|
||||
|
||||
func (p *Proxy) ServeHTTP(wr http.ResponseWriter, req *http.Request) {
|
||||
client := newClient()
|
||||
proxyReq, err := http.NewRequest(req.Method, "http://localhost"+req.RequestURI, req.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
proxyReq.Header = req.Header
|
||||
proxyReq.Header["X-CSC-ADFS-Username"] = []string{username}
|
||||
proxyReq.Header["X-CSC-ADFS-FirstName"] = []string{firstName}
|
||||
resp, err := client.Do(proxyReq)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for k, v := range resp.Header {
|
||||
wr.Header()[k] = v
|
||||
}
|
||||
wr.WriteHeader(resp.StatusCode)
|
||||
_, err = io.Copy(wr, resp.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
sockPtr := flag.String("s", "", "socket path to proxy to")
|
||||
portPtr := flag.Int("p", 9988, "port to listen on")
|
||||
usernamePtr := flag.String("u", "", "username")
|
||||
firstNamePtr := flag.String("f", "", "first name")
|
||||
flag.Parse()
|
||||
sockPath = *sockPtr
|
||||
port = *portPtr
|
||||
username = *usernamePtr
|
||||
firstName = *firstNamePtr
|
||||
if sockPath == "" || username == "" || firstName == "" {
|
||||
fmt.Fprint(os.Stderr, "Usage: proxy [-p port] -s <socket path> -u <username> -f <first name>\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
fmt.Fprintf(os.Stderr, "Listening on %s\n", addr)
|
||||
err := http.ListenAndServe(addr, &Proxy{})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
[Unit]
|
||||
Description=CSC Electronic Office daemon (web)
|
||||
Documentation=https://git.csclub.uwaterloo.ca/public/pyceo
|
||||
Wants=apache2.service
|
||||
After=apache2.service
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
WorkingDirectory=/srv/pyceo/web
|
||||
ExecStart=/srv/pyceo/web/ceod-web -c prod.json
|
||||
RuntimeDirectory=ceod-web
|
||||
DynamicUser=yes
|
||||
LoadCredential=krb5.keytab:/etc/krb5.keytab
|
||||
Environment=KRB5_KTNAME=%d/krb5.keytab
|
||||
ProtectSystem=strict
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
|
@ -1,202 +0,0 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/api"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/service"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
|
||||
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
|
||||
)
|
||||
|
||||
// Make sure app_url is using "127.0.0.1", not "localhost", because that is
|
||||
// what httptest.Server uses, and the cookie domain needs to match exactly
|
||||
var Cfg = config.NewConfig("tests/test.json")
|
||||
var Ceod = service.NewCeodService(Cfg)
|
||||
var TestMail = TestMailBackend{}
|
||||
var TestServer = httptest.NewServer(api.NewAPI(
|
||||
Cfg, Ceod, service.NewMailServiceWithBackend(Cfg, &TestMail),
|
||||
))
|
||||
|
||||
type MockEmail struct {
|
||||
MTA string
|
||||
From string
|
||||
To []string
|
||||
Msg []byte
|
||||
}
|
||||
type TestMailBackend struct {
|
||||
SentEmails []MockEmail
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func (m *TestMailBackend) Send(addr string, from string, to []string, msg []byte) error {
|
||||
msg = bytes.ReplaceAll(msg, []byte("\r\n"), []byte("\n"))
|
||||
m.mutex.Lock()
|
||||
m.SentEmails = append(m.SentEmails, MockEmail{addr, from, to, msg})
|
||||
m.mutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (m *TestMailBackend) Reset() {
|
||||
m.SentEmails = nil
|
||||
}
|
||||
|
||||
var simpleUsernameCounter int
|
||||
var simpleUsernameMutex sync.Mutex
|
||||
|
||||
func getNextSimpleUsername() string {
|
||||
simpleUsernameMutex.Lock()
|
||||
simpleUsernameCounter += 1
|
||||
i := simpleUsernameCounter
|
||||
simpleUsernameMutex.Unlock()
|
||||
return fmt.Sprintf("ceodweb%d", i)
|
||||
}
|
||||
|
||||
type TestLogger struct{}
|
||||
|
||||
var SharedTestLogger TestLogger
|
||||
|
||||
func (t *TestLogger) Debug(i ...interface{}) {}
|
||||
func (t *TestLogger) Debugf(format string, args ...interface{}) {}
|
||||
func (t *TestLogger) Info(i ...interface{}) {}
|
||||
func (t *TestLogger) Infof(format string, args ...interface{}) {}
|
||||
func (t *TestLogger) Warn(i ...interface{}) {
|
||||
log.Println(i...)
|
||||
}
|
||||
func (t *TestLogger) Warnf(format string, args ...interface{}) {
|
||||
log.Printf(format+"\n", args...)
|
||||
}
|
||||
func (t *TestLogger) Error(i ...interface{}) {
|
||||
log.Println(i...)
|
||||
}
|
||||
func (t *TestLogger) Errorf(format string, args ...interface{}) {
|
||||
log.Printf(format+"\n", args...)
|
||||
}
|
||||
|
||||
type TestCeodTransactionRequestContext struct {
|
||||
logger *TestLogger
|
||||
cancelChan <-chan struct{}
|
||||
}
|
||||
|
||||
func (t *TestCeodTransactionRequestContext) Log() logging.Logger {
|
||||
return t.logger
|
||||
}
|
||||
func (t *TestCeodTransactionRequestContext) CancelChan() <-chan struct{} {
|
||||
return t.cancelChan
|
||||
}
|
||||
|
||||
func NewCeodTransactionRequestContext(cancelChan <-chan struct{}) model.CeodTransactionRequestContext {
|
||||
return &TestCeodTransactionRequestContext{logger: &SharedTestLogger, cancelChan: cancelChan}
|
||||
}
|
||||
|
||||
func handleTxn[T any](txnChan <-chan model.ITransactionMessage, err error) *T {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for msg := range txnChan {
|
||||
if _, ok := msg.(*model.TransactionInProgress); ok {
|
||||
// ignore
|
||||
} else if abortedMsg, ok := msg.(*model.TransactionAborted); ok {
|
||||
panic(abortedMsg.Error)
|
||||
} else if completedMsg, ok := msg.(*model.TransactionCompleted[T]); ok {
|
||||
return &completedMsg.Result
|
||||
} else {
|
||||
panic(fmt.Errorf("Unrecognized message type: %+v", msg))
|
||||
}
|
||||
}
|
||||
panic("Did not receive completed transaction message")
|
||||
}
|
||||
|
||||
func AddSimpleUser() *model.UserWithPassword {
|
||||
username := getNextSimpleUsername()
|
||||
cancelChan := make(chan struct{})
|
||||
defer close(cancelChan)
|
||||
ctx := NewCeodTransactionRequestContext(cancelChan)
|
||||
req := model.AddUserRequest{
|
||||
Uid: username,
|
||||
Cn: "John Doe",
|
||||
GivenName: "John",
|
||||
Sn: "Doe",
|
||||
Program: "MAT/Mathematics Computer Science",
|
||||
Terms: 1,
|
||||
ForwardingAddresses: []string{username + "@" + Cfg.UWDomain},
|
||||
}
|
||||
return handleTxn[model.UserWithPassword](Ceod.AddUser(ctx, &req))
|
||||
}
|
||||
|
||||
func DeleteUser(username string) {
|
||||
cancelChan := make(chan struct{})
|
||||
defer close(cancelChan)
|
||||
ctx := NewCeodTransactionRequestContext(cancelChan)
|
||||
result := *handleTxn[string](Ceod.DeleteUser(ctx, username))
|
||||
if result != "OK" {
|
||||
panic(fmt.Errorf("Result was not 'OK': '%s'", result))
|
||||
}
|
||||
}
|
||||
|
||||
type TestClient struct {
|
||||
User *model.UserWithPassword
|
||||
client http.Client
|
||||
}
|
||||
|
||||
func NewTestClientFromUser(user *model.UserWithPassword) *TestClient {
|
||||
return &TestClient{User: user, client: http.Client{}}
|
||||
}
|
||||
|
||||
func NewTestClient() *TestClient {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &TestClient{
|
||||
User: AddSimpleUser(),
|
||||
client: http.Client{Jar: jar},
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy must be called if the client was created with NewTestClient()
|
||||
func (c *TestClient) Destroy() {
|
||||
DeleteUser(c.User.Uid)
|
||||
}
|
||||
|
||||
func (c *TestClient) request(method, urlPath string, formData url.Values) (*goquery.Document, error) {
|
||||
var bodyReader io.Reader
|
||||
if formData != nil {
|
||||
bodyReader = strings.NewReader(formData.Encode())
|
||||
}
|
||||
r, err := http.NewRequest(method, TestServer.URL+urlPath, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not build HTTP request: %w", err)
|
||||
}
|
||||
if bodyReader != nil {
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
r.Header.Set("X-CSC-ADFS-Username", c.User.Uid)
|
||||
r.Header.Set("X-CSC-ADFS-FirstName", c.User.GivenName)
|
||||
resp, err := c.client.Do(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return goquery.NewDocumentFromReader(resp.Body)
|
||||
|
||||
}
|
||||
|
||||
func (c *TestClient) Get(urlPath string) (*goquery.Document, error) {
|
||||
return c.request("GET", urlPath, nil)
|
||||
}
|
||||
|
||||
func (c *TestClient) Post(urlPath string, formData url.Values) (*goquery.Document, error) {
|
||||
return c.request("POST", urlPath, formData)
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPwreset(t *testing.T) {
|
||||
client := NewTestClient()
|
||||
defer client.Destroy()
|
||||
doc, err := client.Get("/pwreset")
|
||||
require.Nil(t, err)
|
||||
inputs := doc.Find("form input")
|
||||
csrfInput := inputs.Filter(`[name="_csrf"]`)
|
||||
require.Equal(t, 1, csrfInput.Length())
|
||||
csrfToken, ok := csrfInput.Attr("value")
|
||||
require.True(t, ok)
|
||||
submitInput := inputs.Filter(`[type="submit"]`)
|
||||
require.Equal(t, 1, submitInput.Length())
|
||||
submitInputValue, ok := submitInput.Attr("value")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "Reset my password", submitInputValue)
|
||||
|
||||
defer TestMail.Reset()
|
||||
doc, err = client.Post("/pwreset", url.Values{"_csrf": {csrfToken}})
|
||||
require.Nil(t, err)
|
||||
para := doc.Find("p").First()
|
||||
uwEmail := client.User.ForwardingAddresses[0]
|
||||
require.Contains(
|
||||
t,
|
||||
para.Text(),
|
||||
fmt.Sprintf("A new temporary password was sent to %s.", uwEmail),
|
||||
)
|
||||
|
||||
require.Len(t, TestMail.SentEmails, 1)
|
||||
emailMsg := &TestMail.SentEmails[0]
|
||||
require.Equal(t, "ceod+web@csclub.internal", emailMsg.From)
|
||||
require.Equal(t, []string{uwEmail}, emailMsg.To)
|
||||
matched, err := regexp.Match("(?m)^Subject: Computer Science Club Password Reset$", emailMsg.Msg)
|
||||
require.Nil(t, err)
|
||||
require.True(t, matched)
|
||||
emailBodyStr := string(emailMsg.Msg)
|
||||
require.Contains(t, emailBodyStr, "Your new temporary CSC password is:\n\n")
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"app_url": "http://127.0.0.1",
|
||||
"socket_path": "app.sock",
|
||||
"csc_domain": "csclub.internal",
|
||||
"uw_domain": "uwaterloo.internal",
|
||||
"dev": true
|
||||
}
|
Loading…
Reference in New Issue