Compare commits

..

6 Commits

Author SHA1 Message Date
Leon ac9a71437f Fixed KeyError in test due to key 'status' lacking quotations.
continuous-integration/drone/pr Build is passing Details
2024-01-22 20:08:14 -05:00
Leon Zhang bd453990c9 Merge branch 'master' into new-positions
continuous-integration/drone/pr Build is failing Details
2024-01-22 19:47:34 -05:00
Leon 82d375b7f4 Merge branch 'new-positions' of https://git.csclub.uwaterloo.ca/public/pyceo into new-positions
continuous-integration/drone/pr Build is failing Details
2024-01-22 19:43:35 -05:00
Leon c9928c47e5 I need new test data, added spaces to cli output 2024-01-22 19:43:21 -05:00
Leon c9ad827c8b I need new test data, added spaces to cli output
continuous-integration/drone/pr Build is failing Details
2024-01-22 19:38:36 -05:00
Leon 067a559b5a Update available positions in configuration files, pertaining to issue #63
continuous-integration/drone/pr Build is failing Details
Added:
`Ext Affairs Lead`
`Marketing Lead`
`Design Lead`
`Events Lead`
`Reps Lead`
`Mods Lead`
`Photography Lead`
`Codey Bot Lead`
`Other`

Removed:
`IMAPD`
`Librarian`

as roles have been vacant.

Updated relevant tests
2024-01-21 21:58:57 -05:00
77 changed files with 82 additions and 2776 deletions

View File

@ -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

View File

@ -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 || \

View File

@ -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

View File

@ -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 ) )

View File

@ -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

View File

@ -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

4
.gitignore vendored
View File

@ -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/

View File

@ -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? :')

View File

@ -1 +1 @@
1.0.31
1.0.29

View File

@ -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'

View File

@ -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()

View File

@ -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",

View File

@ -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.
"""

View File

@ -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

View File

@ -1,4 +1,3 @@
from .IADLDAPService import IADLDAPService
from .ICloudResourceManager import ICloudResourceManager
from .ICloudStackService import ICloudStackService
from .IKerberosService import IKerberosService

View File

@ -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
)

View File

@ -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

View File

@ -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')

View File

@ -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'):

View File

@ -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')
)

View File

@ -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)

View File

@ -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

View File

@ -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',

View File

@ -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])

View File

@ -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)}

View File

@ -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

15
debian/changelog vendored
View File

@ -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

1
debian/control vendored
View File

@ -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),

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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 \

View File

@ -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

View File

@ -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',

View File

@ -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',

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'

3
web/.gitignore vendored
View File

@ -1,3 +0,0 @@
/ceod-web
/app.sock
/test

View File

@ -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>
```

View File

@ -1,7 +0,0 @@
{
"app_url": "http://localhost:9988",
"socket_path": "app.sock",
"csc_domain": "csclub.internal",
"uw_domain": "uwaterloo.internal",
"dev": true
}

View File

@ -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
)

View File

@ -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=

View File

@ -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)
}

View File

@ -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")
}

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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"
}

View File

@ -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
}

View File

@ -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
}

View File

@ -1,9 +0,0 @@
package internal
import "embed"
//go:embed views/*
var EmbeddedViews embed.FS
//go:embed static/*
var EmbeddedAssets embed.FS

View File

@ -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)
}

View File

@ -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,
)
}

View File

@ -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

View File

@ -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

View File

@ -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;
}

View File

@ -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; }

View File

@ -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 }}

View File

@ -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 }}

View File

@ -1,4 +0,0 @@
{{ define "title" }}{{ .Title }}{{ end }}
{{ define "main" }}
<p>{{ .HtmlFragment }}</p>
{{ end }}

View File

@ -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 &copy; 2024 Computer Science Club of the University of Waterloo
</footer>
</body>
</html>

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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"`
}

View File

@ -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"
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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)
}

View File

@ -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")
}

View File

@ -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
}